diff --git a/.config/nextest.toml b/.config/nextest.toml index 53b4cc20e5d..63a141f3773 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +1,6 @@ +[profile.default] +slow-timeout = { period = "30s", terminate-after = 5 } + [[profile.default.overrides]] -filter = 'test(compress_large_int)' +filter = 'test(compress_large_int | fsst_compress_offsets_overflow_i32)' priority = 100 diff --git a/.github/workflows/bench-pr.yml b/.github/workflows/bench-pr.yml index 1f8236bfb01..cfab4f7fd3b 100644 --- a/.github/workflows/bench-pr.yml +++ b/.github/workflows/bench-pr.yml @@ -38,7 +38,7 @@ jobs: if: github.event.pull_request.head.repo.fork == false with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.event.pull_request.head.sha }} - name: Setup benchmark environment @@ -83,7 +83,7 @@ jobs: - name: Setup AWS CLI if: github.event.pull_request.head.repo.fork == false - uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: arn:aws:iam::245040174862:role/GitHubBenchmarkRole aws-region: us-east-1 @@ -98,16 +98,8 @@ jobs: run: | set -Eeu -o pipefail -x - base_commit_sha=$(\ - curl -L \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - https://api.github.com/repos/vortex-data/vortex/actions/workflows/bench.yml/runs\?branch\=develop\&status\=success\&per_page\=1 \ - | jq -r '.workflow_runs[].head_sha' \ - ) - python3 scripts/s3-download.py s3://vortex-ci-benchmark-results/data.json.gz data.json.gz --no-sign-request - gzip -d -c data.json.gz | grep $base_commit_sha > base.json + gzip -d -c data.json.gz > base.json echo '# Benchmarks: ${{ matrix.benchmark.name }}' > comment.md echo '' >> comment.md diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 9a03c749669..839db948add 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -16,9 +16,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup AWS CLI - uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: arn:aws:iam::245040174862:role/GitHubBenchmarkRole aws-region: us-east-1 @@ -54,7 +54,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup benchmark environment run: sudo bash scripts/setup-benchmark.sh - uses: ./.github/actions/setup-rust @@ -95,7 +95,7 @@ jobs: bash scripts/bench-taskset.sh target/release_debug/${{ matrix.benchmark.id }} --formats ${{ matrix.benchmark.formats }} -d gh-json -o results.json --gh-json-v3 results.v3.jsonl - name: Setup AWS CLI - uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: arn:aws:iam::245040174862:role/GitHubBenchmarkRole aws-region: us-east-1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58653ec0809..5798d1d56b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,14 +29,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: spiraldb/actions/.github/actions/lint-toml@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 validate-workflow-yaml: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Validate YAML file run: | # Lint the workflows and yamllint's configuration file. @@ -57,7 +57,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install uv if: github.repository != 'vortex-data/vortex' @@ -93,7 +93,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Pytest - Vortex @@ -131,11 +131,16 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Docs + env: + # required to make sure docs build for features + RUSTDOCFLAGS: "-D warnings --cfg docsrs" run: | - RUSTDOCFLAGS="-D warnings" cargo doc --profile ci --no-deps + # Build docs for the whole workspace (aside from vortex-python that is having hard time with the python-docs syntax), + # including all private docs. + cargo doc --profile ci --no-deps --document-private-items --workspace --exclude vortex-python # nextest doesn't support doc tests, so we run it here cargo test --profile ci --doc --workspace --all-features --exclude vortex-cxx --exclude vortex-jni --exclude vortex-ffi --exclude xtask --no-fail-fast @@ -174,7 +179,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install wasm32 target if: ${{ matrix.config.target == 'wasm32-unknown-unknown' }} @@ -199,7 +204,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - run: cargo minimal-versions check --direct --workspace --ignore-private @@ -215,7 +220,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install nightly for fmt run: rustup toolchain install $NIGHTLY_TOOLCHAIN --component rustfmt @@ -271,7 +276,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: C/C++ Lint - clang-format run: | git ls-files vortex-cuda vortex-cxx vortex-duckdb vortex-ffi \ @@ -293,7 +298,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install cargo-hack if: github.repository != 'vortex-data/vortex' @@ -325,7 +330,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup (Windows) if: matrix.os == 'windows-x64' run: | @@ -335,10 +340,11 @@ jobs: if: matrix.os == 'windows-x64' run: | cargo nextest run --cargo-profile ci --locked --workspace --all-features --no-fail-fast ` - --exclude vortex-bench --exclude vortex-bench-server ` + --exclude vortex-bench ` --exclude vortex-python --exclude vortex-duckdb ` - --exclude vortex-fuzz --exclude vortex-cuda --exclude vortex-nvcomp ` - --exclude vortex-cub --exclude vortex-test-e2e-cuda --exclude duckdb-bench ` + --exclude vortex-fuzz --exclude vortex-cuda --exclude vortex-cuda-ffi ` + --exclude vortex-nvcomp --exclude vortex-cub --exclude vortex-test-e2e-cuda ` + --exclude duckdb-bench ` --exclude lance-bench --exclude datafusion-bench --exclude random-access-bench ` --exclude compress-bench --exclude xtask --exclude vortex-datafusion ` --exclude gpu-scan-cli --exclude vortex-sqllogictest @@ -346,19 +352,6 @@ jobs: if: matrix.os != 'windows-x64' run: | cargo nextest run --cargo-profile ci --locked --workspace --all-features --no-fail-fast --exclude vortex-bench --exclude xtask --exclude vortex-sqllogictest - - name: vortex-bench-server admin snapshot tests (Linux only - network-dependent) - # The /api/admin/snapshot tests INSTALL+LOAD the vortex DuckDB - # core extension from extensions.duckdb.org on first call. They - # are #[ignore]'d by default so `cargo test` works in offline - # environments (sandboxed CI, local dev without network). This - # step runs them explicitly on the Linux runners, which DO have - # outbound network, so the entire backup contract is covered - # in CI before merge. macOS/arm64 also exercises them - same - # extension, same network. Windows skipped (bench-server is - # excluded from Windows test matrix above). - if: matrix.os == 'linux-x64' || matrix.os == 'linux-arm64' || matrix.os == 'macos-arm64' - run: | - cargo nextest run --cargo-profile ci --locked -p vortex-bench-server --test admin --run-ignored only - uses: ./.github/actions/check-rebuild if: matrix.os != 'windows-x64' with: @@ -384,9 +377,11 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - - run: ./gradlew test --parallel + - run: ./gradlew javadoc + working-directory: ./java + - run: ./gradlew check working-directory: ./java license-check-and-audit-check: @@ -401,7 +396,7 @@ jobs: # Prevent sudden announcement of a new advisory from failing ci: continue-on-error: ${{ matrix.checks == 'advisories' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2 with: command: check ${{ matrix.checks }} @@ -418,7 +413,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Build and run C++ unit tests run: | @@ -447,7 +442,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Run sqllogictest tests run: | @@ -462,7 +457,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -489,7 +484,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install nightly for cbindgen macro expansion run: rustup toolchain install $NIGHTLY_TOOLCHAIN @@ -528,7 +523,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: "regenerate FFI header file" run: | diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 24422c021c8..c95031a19e6 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -232,7 +232,7 @@ jobs: actions: read steps: - name: Checkout same-repo PR contents - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.gate.outputs.checkout_ref }} fetch-depth: 1 @@ -242,7 +242,7 @@ jobs: - name: Run Claude Code in review-only mode id: claude - uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1.0.133 + uses: anthropics/claude-code-action@fbda2eb1bdc90d319b8d853f5deb53bca199a7c1 # v1.0.140 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This workflow deliberately uses the built-in token because it only needs diff --git a/.github/workflows/claude-write.yml b/.github/workflows/claude-write.yml index 45b488c9a56..5d6224fc18b 100644 --- a/.github/workflows/claude-write.yml +++ b/.github/workflows/claude-write.yml @@ -167,7 +167,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ needs.gate.outputs.checkout_ref }} fetch-depth: 0 @@ -197,7 +197,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1.0.133 + uses: anthropics/claude-code-action@fbda2eb1bdc90d319b8d853f5deb53bca199a7c1 # v1.0.140 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/close-fixed-fuzzer-issues.yml b/.github/workflows/close-fixed-fuzzer-issues.yml index bd365070e3c..9e7deda1f97 100644 --- a/.github/workflows/close-fixed-fuzzer-issues.yml +++ b/.github/workflows/close-fixed-fuzzer-issues.yml @@ -36,7 +36,7 @@ jobs: with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 50a5cbaa75a..c819d5178d6 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -34,7 +34,7 @@ jobs: - { shard: 5, name: "Encodings 2", packages: "vortex-decimal-byte-parts vortex-fastlanes vortex-fsst", features: "--features _test-harness" } - { shard: 6, name: "Encodings 3", packages: "vortex-pco vortex-runend vortex-sequence" } - { shard: 7, name: "Encodings 4", packages: "vortex-sparse vortex-zigzag vortex-zstd" } - - { shard: 8, name: "Storage formats", packages: "vortex-flatbuffers vortex-proto vortex-btrblocks" } + - { shard: 8, name: "Storage formats & row encoding", packages: "vortex-flatbuffers vortex-proto vortex-btrblocks vortex-row" } name: "Benchmark with Codspeed (Shard #${{ matrix.shard }})" timeout-minutes: 30 runs-on: >- @@ -46,11 +46,14 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup benchmark environment run: sudo bash scripts/setup-benchmark.sh - uses: ./.github/actions/setup-prebuild - uses: ./.github/actions/system-info + - run: | + sudo apt-get update + sudo apt-get install -y libc6-dbg - name: Install Codspeed uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 with: @@ -82,7 +85,7 @@ jobs: - uses: runs-on/action@v2 with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -91,6 +94,9 @@ jobs: nvidia-smi nvidia-smi -L nvidia-smi -q -d Memory + - run: | + sudo apt-get update + sudo apt-get install -y libc6-dbg - name: Install Codspeed uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 with: diff --git a/.github/workflows/compat-gen-upload.yml b/.github/workflows/compat-gen-upload.yml index 63db97422c8..d3aafe0dfbb 100644 --- a/.github/workflows/compat-gen-upload.yml +++ b/.github/workflows/compat-gen-upload.yml @@ -38,13 +38,13 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - uses: ./.github/actions/setup-prebuild - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: "arn:aws:iam::245040174862:role/GitHubCompatUploadRole" aws-region: us-east-1 @@ -88,13 +88,13 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - uses: ./.github/actions/setup-prebuild - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: "arn:aws:iam::245040174862:role/GitHubCompatUploadRole" aws-region: us-east-1 diff --git a/.github/workflows/compat-validation.yml b/.github/workflows/compat-validation.yml index 7be13aec5b3..e722d6f9f34 100644 --- a/.github/workflows/compat-validation.yml +++ b/.github/workflows/compat-validation.yml @@ -33,7 +33,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Run compat tests run: | diff --git a/.github/workflows/cuda.yaml b/.github/workflows/cuda.yaml index 6edb6ff5cb8..00faca7fd43 100644 --- a/.github/workflows/cuda.yaml +++ b/.github/workflows/cuda.yaml @@ -30,7 +30,7 @@ jobs: - uses: runs-on/action@v2 with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -38,12 +38,13 @@ jobs: with: command: >- cargo build --profile ci --locked --all-features --all-targets - -p vortex-cuda -p vortex-cub -p vortex-nvcomp + -p vortex-cuda -p vortex-cuda-ffi -p vortex-cub -p vortex-nvcomp -p gpu-scan-cli -p vortex-test-e2e-cuda - name: Clippy CUDA crates run: | cargo clippy --profile ci --locked --all-features --all-targets \ -p vortex-cuda \ + -p vortex-cuda-ffi \ -p vortex-cub \ -p vortex-nvcomp \ -p gpu-scan-cli \ @@ -64,12 +65,12 @@ jobs: nvidia-smi nvidia-smi -L nvidia-smi -q -d Memory - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install nextest - uses: taiki-e/install-action@e49978b799e49ff429d162b7a30601a569ab6538 # v2 + uses: taiki-e/install-action@0631aa6515c7d545823c67cfae7ef4fc7f490154 # v2 with: tool: nextest - name: Rust Tests @@ -81,6 +82,7 @@ jobs: --locked \ -p vortex-file \ -p vortex-cuda \ + -p vortex-cuda-ffi \ -p vortex-cub \ -p vortex-nvcomp \ -p vortex-test-e2e-cuda \ @@ -116,7 +118,7 @@ jobs: nvidia-smi nvidia-smi -L nvidia-smi -q -d Memory - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -141,7 +143,7 @@ jobs: nvidia-smi nvidia-smi -L nvidia-smi -q -d Memory - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 506012f55ee..eadc0839c11 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/fuzz-coverage.yml b/.github/workflows/fuzz-coverage.yml index 005b06ef750..a62577313cc 100644 --- a/.github/workflows/fuzz-coverage.yml +++ b/.github/workflows/fuzz-coverage.yml @@ -26,7 +26,7 @@ jobs: with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: diff --git a/.github/workflows/fuzzer-fix-automation.yml b/.github/workflows/fuzzer-fix-automation.yml index e9b9db5c2cf..01df7162e78 100644 --- a/.github/workflows/fuzzer-fix-automation.yml +++ b/.github/workflows/fuzzer-fix-automation.yml @@ -63,7 +63,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: # Don't leave the built-in GITHUB_TOKEN in git config; the fix is # committed and pushed with the App token so its PR triggers CI. @@ -273,7 +273,7 @@ jobs: CRASH_FILE: ${{ steps.extract.outputs.crash_file }} CRASH_FILE_PATH: ${{ steps.download.outputs.crash_file_path }} ARTIFACT_URL: ${{ steps.extract.outputs.artifact_url }} - uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1 + uses: anthropics/claude-code-action@fbda2eb1bdc90d319b8d853f5deb53bca199a7c1 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Use the App token (not GITHUB_TOKEN) so the committed fix branch and diff --git a/.github/workflows/minimize_fuzz_corpus_workflow.yml b/.github/workflows/minimize_fuzz_corpus_workflow.yml index e3b2a9c11e6..f6c1e8d8df1 100644 --- a/.github/workflows/minimize_fuzz_corpus_workflow.yml +++ b/.github/workflows/minimize_fuzz_corpus_workflow.yml @@ -51,7 +51,7 @@ jobs: with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: diff --git a/.github/workflows/musl.yml b/.github/workflows/musl.yml index 13f5902db2e..cf8ff08eeb6 100644 --- a/.github/workflows/musl.yml +++ b/.github/workflows/musl.yml @@ -28,52 +28,48 @@ jobs: ${{ github.repository == 'vortex-data/vortex' && format('runs-on={0}/runner=amd64-large/image=ubuntu24-full-x64-pre-v2/tag=rust-build-musl', github.run_id) || 'ubuntu-latest' }} + container: + image: ghcr.io/rust-lang/rust:1-alpine3.21 + credentials: + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + env: + # -crt-static makes build-script binaries dynamically linked so bindgen + # (custom-labels, a transitive dep of vortex-io) can dlopen libclang; a + # fully static musl build script panics with "Dynamic loading not supported". + RUSTFLAGS: "-A warnings -C target-feature=-crt-static" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - # Build and test natively inside Alpine so the musl C/C++ toolchain (gcc, g++) and - # GNU ld are used. Cross-compiling from the glibc host is not viable: musl-tools - # ships no musl-g++ for C++ deps such as custom-labels, and zig's linker rejects the - # --dynamic-list arg those deps emit. Checkout runs on the host because JS actions - # cannot run inside an Alpine (musl) container, so this job provisions rustup + - # nextest itself rather than using the prebuilt toolchain / sccache setup that the - # glibc test runners use. - # # Exclusions: # - CUDA crates have no CUDA toolkit / musl target (as in the wasm build). # - vortex-duckdb and its dependents can't build standalone for musl: its # build.rs has no prebuilt DuckDB for musl. The DuckDB extension builds it # against a musl DuckDB it compiles itself, so it is exercised there instead. - - name: Build & test workspace for x86_64-unknown-linux-musl + - name: Install Alpine packages + # tzdata provides /usr/share/zoneinfo; without it Alpine has no timezone + # database and datetime tests fail with "failed to find time zone `UTC`". run: | - docker run --rm -v "${GITHUB_WORKSPACE}:/work" -w /work alpine:3.21 sh -euc ' - # tzdata provides /usr/share/zoneinfo; without it Alpine has no timezone - # database and datetime tests fail with "failed to find time zone `UTC`". - apk add --no-cache build-base clang clang-dev llvm-dev cmake make perl \ - pkgconf protobuf protobuf-dev openssl-dev zstd-dev bash git curl ca-certificates \ - python3 python3-dev tar tzdata - # -crt-static makes build-script binaries dynamically linked so bindgen - # (custom-labels, a transitive dep of vortex-io) can dlopen libclang; a - # fully static musl build script panics with "Dynamic loading not supported". - export CARGO_HOME=/root/.cargo CARGO_TARGET_DIR=/tmp/target RUSTFLAGS="-A warnings -C target-feature=-crt-static" - export PATH="$CARGO_HOME/bin:$PATH" - curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain none - rustup show - # Prebuilt static musl nextest binary; building it from source would - # roughly double the cold-cache compile time of this job. - curl -LsSf https://get.nexte.st/latest/linux-musl \ - | tar zxf - -C "$CARGO_HOME/bin" - # The workspace is bind-mounted from the host runner and owned by a - # different uid than the container root, so git rejects it as "dubious - # ownership" and `git rev-parse HEAD` returns empty. vortex-bench and the - # benchmarks website embed that SHA in snapshots (redacted to / - # ); an empty SHA breaks the redaction and fails 10 snapshot tests. - git config --global --add safe.directory /work - cargo nextest run --cargo-profile ci --locked --workspace --no-fail-fast \ - --exclude vortex-cuda --exclude vortex-cub --exclude vortex-nvcomp \ - --exclude gpu-scan-cli --exclude vortex-test-e2e-cuda \ - --exclude vortex-duckdb --exclude duckdb-bench --exclude vortex-sqllogictest - ' + apk add --no-cache build-base clang clang-dev llvm-dev cmake make perl \ + pkgconf protobuf protobuf-dev openssl-dev zstd-dev git curl ca-certificates \ + python3 python3-dev tar tzdata bash + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Install nextest + shell: bash + # Prebuilt static musl nextest binary; building it from source would + # roughly double the cold-cache compile time of this job. + run: | + curl -LsSf https://get.nexte.st/latest/linux-musl | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin + + - name: Run workspace tests for x86_64-unknown-linux-musl + shell: bash + run: | + cargo nextest run --cargo-profile ci --locked --workspace --no-fail-fast \ + --exclude vortex-cuda --exclude vortex-cub --exclude vortex-nvcomp \ + --exclude gpu-scan-cli --exclude vortex-test-e2e-cuda \ + --exclude vortex-duckdb --exclude duckdb-bench --exclude vortex-sqllogictest \ + --exclude vortex-bench --exclude lance-bench --exclude datafusion-bench --exclude vortex-datafusion \ + --exclude compress-bench --exclude random-access-bench --exclude vortex-bench-server - name: Alert incident.io if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/develop' diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 41bd61b9828..cc4e68d0eff 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -31,7 +31,7 @@ jobs: - { os: ubuntu, runs-on: "ubuntu-latest", target: aarch64-unknown-linux-gnu } - { os: ubuntu, runs-on: "ubuntu-latest", target: x86_64-unknown-linux-gnu } steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 @@ -86,7 +86,7 @@ jobs: runs-on: "macos-latest" timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 @@ -121,7 +121,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 diff --git a/.github/workflows/publish-benchmarks-website.yml b/.github/workflows/publish-benchmarks-website.yml index b73e176a79a..be1def626a0 100644 --- a/.github/workflows/publish-benchmarks-website.yml +++ b/.github/workflows/publish-benchmarks-website.yml @@ -14,7 +14,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Log in to GHCR uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 diff --git a/.github/workflows/publish-dry-runs.yml b/.github/workflows/publish-dry-runs.yml index 1da1b9322a7..7b6a750260b 100644 --- a/.github/workflows/publish-dry-runs.yml +++ b/.github/workflows/publish-dry-runs.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Rust Dependency Cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -79,7 +79,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 @@ -112,7 +112,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install cargo-edit uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 010aee0bd80..6398c54c20e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,7 +23,7 @@ jobs: timeout-minutes: 30 needs: [package] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - uses: ./.github/actions/setup-rust @@ -88,7 +88,7 @@ jobs: run: working-directory: ./java steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: distribution: "corretto" diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 4d6620f85ed..64ecafd2e77 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -44,7 +44,7 @@ jobs: with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/report-fuzz-crash.yml b/.github/workflows/report-fuzz-crash.yml index 3c5aef0b32e..ea68e9a832a 100644 --- a/.github/workflows/report-fuzz-crash.yml +++ b/.github/workflows/report-fuzz-crash.yml @@ -49,7 +49,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Download fuzzer logs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 @@ -118,7 +118,7 @@ jobs: steps.dedup.outputs.duplicate != 'true' || steps.dedup.outputs.confidence != 'exact' continue-on-error: true - uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1 + uses: anthropics/claude-code-action@fbda2eb1bdc90d319b8d853f5deb53bca199a7c1 # v1 with: claude_code_oauth_token: ${{ secrets.claude_code_oauth_token }} github_token: ${{ secrets.gh_token }} diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml index 6d871fd20d4..3028dbbd8f1 100644 --- a/.github/workflows/reuse.yml +++ b/.github/workflows/reuse.yml @@ -17,6 +17,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: REUSE Compliance Check uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6 diff --git a/.github/workflows/run-fuzzer.yml b/.github/workflows/run-fuzzer.yml index 3ce424003e7..a3a41a1a53e 100644 --- a/.github/workflows/run-fuzzer.yml +++ b/.github/workflows/run-fuzzer.yml @@ -76,7 +76,7 @@ jobs: with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: diff --git a/.github/workflows/rust-instrumented.yml b/.github/workflows/rust-instrumented.yml index 1233021cf88..d58463d53d2 100644 --- a/.github/workflows/rust-instrumented.yml +++ b/.github/workflows/rust-instrumented.yml @@ -33,7 +33,7 @@ jobs: - suite: tests runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-large/image=ubuntu24-full-x64-pre-v2/tag=rust-coverage-suite-{1}', github.run_id, matrix.suite) + && format('runs-on={0}/runner=amd64-large/disk=large/image=ubuntu24-full-x64-pre-v2/tag=rust-coverage-suite-{1}', github.run_id, matrix.suite) || 'ubuntu-latest' }} env: RUSTFLAGS: "-Cinstrument-coverage -A warnings" @@ -45,7 +45,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Ensure llvm-tools are installed run: | @@ -86,7 +86,7 @@ jobs: -o ${{ env.GRCOV_OUTPUT_FILE }} test -s ${{ env.GRCOV_OUTPUT_FILE }} - name: Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: name: run-${{ matrix.suite }} files: ${{ env.GRCOV_OUTPUT_FILE }} @@ -128,7 +128,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install Rust nightly toolchain run: | @@ -137,27 +137,30 @@ jobs: - name: Build tests with sanitizer run: | RUSTFLAGS="${RUSTFLAGS} ${{ matrix.sanitizer_flags }}" \ - cargo +$NIGHTLY_TOOLCHAIN build --locked --all-features \ + cargo +$NIGHTLY_TOOLCHAIN build --tests --locked --all-features \ --target x86_64-unknown-linux-gnu -Zbuild-std \ -p vortex-buffer -p vortex-fastlanes -p vortex-fsst -p vortex-alp -p vortex-array - name: Run tests with sanitizer run: | RUSTFLAGS="${RUSTFLAGS} ${{ matrix.sanitizer_flags }}" \ - cargo +$NIGHTLY_TOOLCHAIN nextest run --locked --all-features \ - --target x86_64-unknown-linux-gnu --no-fail-fast -Zbuild-std \ + cargo +$NIGHTLY_TOOLCHAIN nextest run --locked --all-features --no-fail-fast \ + --target x86_64-unknown-linux-gnu -Zbuild-std \ -p vortex-buffer -p vortex-fastlanes -p vortex-fsst -p vortex-alp -p vortex-array # vortex-ffi requires --no-default-features as otherwise we pull in # Mimalloc which interferes with sanitizers # cargo nextest reports less sanitizer issues than cargo test # TODO(myrrc): remove --no-default-features once we make Mimalloc opt-in + # --tests skips doctests: rustdoc ignores RUSTFLAGS (it uses RUSTDOCFLAGS), + # so doctests would build vortex-ffi without -Zsanitizer and mismatch the + # sanitizer-built deps. - name: Run vortex-ffi tests with sanitizer run: | RUSTFLAGS="${RUSTFLAGS} ${{ matrix.sanitizer_flags }}" \ cargo +$NIGHTLY_TOOLCHAIN test --locked --no-default-features \ --target x86_64-unknown-linux-gnu --no-fail-fast -Zbuild-std \ - -p vortex-ffi -- --no-capture + -p vortex-ffi --tests -- --no-capture rust-ffi-test-sanitizer: strategy: @@ -192,7 +195,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install rustfilt run: | @@ -211,8 +214,8 @@ jobs: --target x86_64-unknown-linux-gnu -Zbuild-std \ -p vortex-ffi - name: Build FFI library tests and examples + working-directory: vortex-ffi run: | - cd vortex-ffi cmake -Bbuild -DBUILD_TESTS=1 -DBUILD_EXAMPLES=1 -DSANITIZER=${{ matrix.sanitizer }} -DTARGET_TRIPLE="x86_64-unknown-linux-gnu" cmake --build build -j - name: Run tests @@ -249,7 +252,7 @@ jobs: if: github.repository == 'vortex-data/vortex' with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-prebuild - name: Install nightly with miri run: rustup toolchain install $NIGHTLY_TOOLCHAIN --component rust-src,rustfmt,clippy,miri diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 48b17b9ac42..b46fe455a34 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -300,12 +300,12 @@ jobs: if: inputs.mode != 'pr' || github.event.pull_request.head.repo.fork == false with: sccache: s3 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 if: inputs.mode == 'pr' with: ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 if: inputs.mode != 'pr' - name: Setup benchmark environment run: sudo bash scripts/setup-benchmark.sh @@ -362,7 +362,7 @@ jobs: - name: Setup AWS CLI if: inputs.mode != 'pr' || github.event.pull_request.head.repo.fork == false - uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: arn:aws:iam::245040174862:role/GitHubBenchmarkRole aws-region: us-east-1 @@ -443,16 +443,8 @@ jobs: run: | set -Eeu -o pipefail -x - base_commit_sha=$(\ - curl -L \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - https://api.github.com/repos/vortex-data/vortex/actions/workflows/bench.yml/runs\?branch\=develop\&status\=success\&per_page\=1 \ - | jq -r '.workflow_runs[].head_sha' \ - ) - python3 scripts/s3-download.py s3://vortex-ci-benchmark-results/data.json.gz data.json.gz --no-sign-request - gzip -d -c data.json.gz | grep $base_commit_sha > base.json + gzip -d -c data.json.gz > base.json echo '# Benchmarks: ${{ matrix.name }}' > comment.md echo '' >> comment.md diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index b93dea673b1..e05120f8843 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -19,6 +19,6 @@ jobs: timeout-minutes: 10 steps: - name: Checkout Actions Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Spell Check Repo - uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 # v1.47.0 + uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # v1.47.2 diff --git a/.github/workflows/v3-commit-metadata.yml b/.github/workflows/v3-commit-metadata.yml index 9307cc394f9..d95da69207b 100644 --- a/.github/workflows/v3-commit-metadata.yml +++ b/.github/workflows/v3-commit-metadata.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 2 diff --git a/.github/workflows/wasm-fuzz.yml b/.github/workflows/wasm-fuzz.yml index f076890c5b1..a2cec6c6c14 100644 --- a/.github/workflows/wasm-fuzz.yml +++ b/.github/workflows/wasm-fuzz.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 240 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index da0cdae16b6..cee88d0e43b 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -28,7 +28,7 @@ jobs: outputs: web: ${{ steps.filter.outputs.web }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 id: filter with: @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 8b66b77abfb..015f4a1b2d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher", "cpubits", @@ -145,35 +145,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "apache-avro" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf" -dependencies = [ - "bigdecimal", - "bon", - "bzip2", - "crc32fast", - "digest 0.10.7", - "liblzma", - "log", - "miniz_oxide", - "num-bigint", - "quad-rand", - "rand 0.9.4", - "regex-lite", - "serde", - "serde_bytes", - "serde_json", - "snap", - "strum 0.27.2", - "strum_macros 0.27.2", - "thiserror 2.0.18", - "uuid", - "zstd", -] - [[package]] name = "approx" version = "0.5.1" @@ -197,9 +168,6 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] [[package]] name = "arc-swap" @@ -282,6 +250,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrow-avro" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049230728cd6e093088c8d231b4beede184e35cad7777c1505c0d5a8571f4376" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "bytes", + "bzip2", + "crc", + "flate2", + "indexmap 2.14.0", + "liblzma", + "rand 0.9.4", + "serde", + "serde_json", + "snap", + "strum_macros 0.28.0", + "uuid", + "zstd", +] + [[package]] name = "arrow-buffer" version = "58.3.0" @@ -515,7 +507,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -557,7 +549,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -583,7 +575,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -680,58 +672,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base16ct" version = "1.0.0" @@ -787,7 +727,6 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", - "serde", ] [[package]] @@ -821,9 +760,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitpacking" @@ -901,31 +840,6 @@ dependencies = [ "piper", ] -[[package]] -name = "bon" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" -dependencies = [ - "darling", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - [[package]] name = "borsh" version = "1.6.1" @@ -963,9 +877,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -997,6 +911,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -1183,9 +1103,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1305,9 +1225,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "codespan-reporting" @@ -1439,16 +1359,15 @@ version = "7.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" dependencies = [ - "crossterm 0.28.1", "unicode-segmentation", "unicode-width 0.2.2", ] [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -1486,7 +1405,6 @@ version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ - "brotli", "bzip2", "compression-core", "flate2", @@ -1645,6 +1563,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" @@ -1669,6 +1593,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1688,6 +1627,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1741,19 +1686,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "parking_lot", - "rustix 0.38.44", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" @@ -1767,7 +1699,7 @@ dependencies = [ "futures-core", "mio", "parking_lot", - "rustix 1.1.4", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -1979,45 +1911,92 @@ dependencies = [ "arrow-schema", "async-trait", "bytes", + "chrono", + "datafusion-catalog 53.1.0", + "datafusion-catalog-listing 53.1.0", + "datafusion-common 53.1.0", + "datafusion-common-runtime 53.1.0", + "datafusion-datasource 53.1.0", + "datafusion-datasource-arrow 53.1.0", + "datafusion-datasource-csv 53.1.0", + "datafusion-datasource-json 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-functions 53.1.0", + "datafusion-functions-aggregate 53.1.0", + "datafusion-functions-nested 53.1.0", + "datafusion-functions-table 53.1.0", + "datafusion-functions-window 53.1.0", + "datafusion-optimizer 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-adapter 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-optimizer 53.1.0", + "datafusion-physical-plan 53.1.0", + "datafusion-session 53.1.0", + "datafusion-sql 53.1.0", + "futures", + "itertools 0.14.0", + "log", + "object_store 0.13.2", + "parking_lot", + "rand 0.9.4", + "regex", + "sqlparser 0.61.0", + "tempfile", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "datafusion" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997a31e15872606a49478e670c58302094c97cb96abb0a7d60720f8e92170040" +dependencies = [ + "arrow", + "arrow-schema", + "async-trait", "bzip2", "chrono", - "datafusion-catalog", - "datafusion-catalog-listing", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-datasource-arrow", + "datafusion-catalog 54.0.0", + "datafusion-catalog-listing 54.0.0", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-datasource-arrow 54.0.0", "datafusion-datasource-avro", - "datafusion-datasource-csv", - "datafusion-datasource-json", + "datafusion-datasource-csv 54.0.0", + "datafusion-datasource-json 54.0.0", "datafusion-datasource-parquet", - "datafusion-execution", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-functions", - "datafusion-functions-aggregate", - "datafusion-functions-nested", - "datafusion-functions-table", - "datafusion-functions-window", - "datafusion-optimizer", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-optimizer", - "datafusion-physical-plan", - "datafusion-session", - "datafusion-sql", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-functions 54.0.0", + "datafusion-functions-aggregate 54.0.0", + "datafusion-functions-nested 54.0.0", + "datafusion-functions-table 54.0.0", + "datafusion-functions-window 54.0.0", + "datafusion-optimizer 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-adapter 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-optimizer 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-session 54.0.0", + "datafusion-sql 54.0.0", "flate2", "futures", + "indexmap 2.14.0", "itertools 0.14.0", "liblzma", "log", "object_store 0.13.2", "parking_lot", "parquet", - "rand 0.9.4", - "regex", - "sqlparser", + "sqlparser 0.62.0", "tempfile", "tokio", "url", @@ -2032,9 +2011,9 @@ dependencies = [ "anyhow", "clap", "custom-labels", - "datafusion", - "datafusion-common", - "datafusion-physical-plan", + "datafusion 54.0.0", + "datafusion-common 54.0.0", + "datafusion-physical-plan 54.0.0", "futures", "itertools 0.14.0", "object_store 0.13.2", @@ -2060,14 +2039,39 @@ dependencies = [ "arrow", "async-trait", "dashmap", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-plan", - "datafusion-session", + "datafusion-common 53.1.0", + "datafusion-common-runtime 53.1.0", + "datafusion-datasource 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-plan 53.1.0", + "datafusion-session 53.1.0", + "futures", + "itertools 0.14.0", + "log", + "object_store 0.13.2", + "parking_lot", + "tokio", +] + +[[package]] +name = "datafusion-catalog" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7dd61161508f8f5fa1107774ea687bd753c22d83a32eebf963549f89de14139" +dependencies = [ + "arrow", + "async-trait", + "dashmap", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-session 54.0.0", "futures", "itertools 0.14.0", "log", @@ -2084,15 +2088,38 @@ checksum = "17e112307715d6a7a331111a4c2330ff54bc237183511c319e3708a4cff431fb" dependencies = [ "arrow", "async-trait", - "datafusion-catalog", - "datafusion-common", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-plan", + "datafusion-catalog 53.1.0", + "datafusion-common 53.1.0", + "datafusion-datasource 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-adapter 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-plan 53.1.0", + "futures", + "itertools 0.14.0", + "log", + "object_store 0.13.2", +] + +[[package]] +name = "datafusion-catalog-listing" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897c70f871277f9ce99aa38347be0d679bbe3e617156c4d2a8378cec8a2a0891" +dependencies = [ + "arrow", + "async-trait", + "datafusion-catalog 54.0.0", + "datafusion-common 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-adapter 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", "futures", "itertools 0.14.0", "log", @@ -2106,7 +2133,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d72a11ca44a95e1081870d3abb80c717496e8a7acb467a1d3e932bb636af5cc2" dependencies = [ "ahash 0.8.12", - "apache-avro", "arrow", "arrow-ipc", "chrono", @@ -2117,11 +2143,35 @@ dependencies = [ "libc", "log", "object_store 0.13.2", - "parquet", "paste", + "sqlparser 0.61.0", + "tokio", + "web-time", +] + +[[package]] +name = "datafusion-common" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c9ded5d87d9172319e006f2afdb9928d72dbacd6a90a458d8acb1e3b43a65" +dependencies = [ + "arrow", + "arrow-ipc", + "arrow-schema", + "chrono", + "foldhash 0.2.0", + "half", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "libc", + "log", + "object_store 0.13.2", + "parquet", "recursive", - "sqlparser", + "sqlparser 0.62.0", "tokio", + "uuid", "web-time", ] @@ -2136,11 +2186,51 @@ dependencies = [ "tokio", ] +[[package]] +name = "datafusion-common-runtime" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "981b9dae74f78ee3d9f714fb49b01919eab975461b56149510c3ba9ea11287d1" +dependencies = [ + "futures", + "log", + "tokio", +] + [[package]] name = "datafusion-datasource" version = "53.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9fb386e1691355355a96419978a0022b7947b44d4a24a6ea99f00b6b485cbb6" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "chrono", + "datafusion-common 53.1.0", + "datafusion-common-runtime 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-adapter 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-plan 53.1.0", + "datafusion-session 53.1.0", + "futures", + "glob", + "itertools 0.14.0", + "log", + "object_store 0.13.2", + "rand 0.9.4", + "tokio", + "url", +] + +[[package]] +name = "datafusion-datasource" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd7d295b2ec7c00d8a56562f41ed41062cf0af75549ed891c12a0a09eddfefe" dependencies = [ "arrow", "async-compression", @@ -2148,15 +2238,15 @@ dependencies = [ "bytes", "bzip2", "chrono", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-adapter 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-session 54.0.0", "flate2", "futures", "glob", @@ -2164,6 +2254,7 @@ dependencies = [ "liblzma", "log", "object_store 0.13.2", + "parking_lot", "rand 0.9.4", "tokio", "tokio-util", @@ -2181,14 +2272,14 @@ dependencies = [ "arrow-ipc", "async-trait", "bytes", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", + "datafusion-common 53.1.0", + "datafusion-common-runtime 53.1.0", + "datafusion-datasource 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-plan 53.1.0", + "datafusion-session 53.1.0", "futures", "itertools 0.14.0", "object_store 0.13.2", @@ -2196,22 +2287,45 @@ dependencies = [ ] [[package]] -name = "datafusion-datasource-avro" -version = "53.1.0" +name = "datafusion-datasource-arrow" +version = "54.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a579c3bd290c66ea4b269493e75e8a3ed42c9c895a651f10210a29538aee50c4" +checksum = "552b0b3f342f7ec41b3fbd70f6339dc82a30cfd0349e7f280e7852528085349f" dependencies = [ - "apache-avro", "arrow", + "arrow-ipc", "async-trait", "bytes", - "datafusion-common", - "datafusion-datasource", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-session 54.0.0", + "futures", + "itertools 0.14.0", + "object_store 0.13.2", + "tokio", +] + +[[package]] +name = "datafusion-datasource-avro" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb517d08967d536284ce70afb5fe8583133779249f2d7b90587d339741a7f195" +dependencies = [ + "arrow", + "arrow-avro", + "async-trait", + "bytes", + "datafusion-common 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-physical-expr-adapter 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-session 54.0.0", "futures", - "num-traits", "object_store 0.13.2", ] @@ -2224,14 +2338,37 @@ dependencies = [ "arrow", "async-trait", "bytes", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", + "datafusion-common 53.1.0", + "datafusion-common-runtime 53.1.0", + "datafusion-datasource 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-plan 53.1.0", + "datafusion-session 53.1.0", + "futures", + "object_store 0.13.2", + "regex", + "tokio", +] + +[[package]] +name = "datafusion-datasource-csv" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68850aa426b897e879c8b87e512ea8124f1d0a2869a4e51808ddaaddf1bc0ada" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-session 54.0.0", "futures", "object_store 0.13.2", "regex", @@ -2247,14 +2384,14 @@ dependencies = [ "arrow", "async-trait", "bytes", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", + "datafusion-common 53.1.0", + "datafusion-common-runtime 53.1.0", + "datafusion-datasource 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-plan 53.1.0", + "datafusion-session 53.1.0", "futures", "object_store 0.13.2", "serde_json", @@ -2262,27 +2399,51 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "datafusion-datasource-json" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402f93242ae08ef99139ee2c528a49d087efe88d5c7b2c3ff5480855a40ce54f" +dependencies = [ + "arrow", + "async-trait", + "bytes", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-session 54.0.0", + "futures", + "object_store 0.13.2", + "tokio", + "tokio-stream", +] + [[package]] name = "datafusion-datasource-parquet" -version = "53.1.0" +version = "54.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a8e0365e0e08e8ff94d912f0ababcf9065a1a304018ba90b1fc83c855b4997" +checksum = "ffd2499c1bee0eeccf6a57156105700eeeb17bc701899ac719183c4e74231450" dependencies = [ "arrow", "async-trait", "bytes", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions-aggregate-common", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-pruning", - "datafusion-session", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions 54.0.0", + "datafusion-functions-aggregate-common 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-adapter 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-pruning 54.0.0", + "datafusion-session 54.0.0", "futures", "itertools 0.14.0", "log", @@ -2298,6 +2459,12 @@ version = "53.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de6ac0df1662b9148ad3c987978b32cbec7c772f199b1d53520c8fa764a87ee" +[[package]] +name = "datafusion-doc" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9e7e5d11130c48c8bd4e80c79a9772dd28ce6dc330baca9246205d245b9e2e" + [[package]] name = "datafusion-execution" version = "53.1.0" @@ -2309,9 +2476,31 @@ dependencies = [ "async-trait", "chrono", "dashmap", - "datafusion-common", - "datafusion-expr", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "futures", + "log", + "object_store 0.13.2", + "parking_lot", + "rand 0.9.4", + "tempfile", + "url", +] + +[[package]] +name = "datafusion-execution" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a8643ab852eb68864e1b72ae789e8066282dce48eea6347ffb0aee33d1ccc0" +dependencies = [ + "arrow", + "arrow-buffer", + "async-trait", + "dashmap", + "datafusion-common 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", "futures", "log", "object_store 0.13.2", @@ -2330,18 +2519,40 @@ dependencies = [ "arrow", "async-trait", "chrono", - "datafusion-common", - "datafusion-doc", - "datafusion-expr-common", - "datafusion-functions-aggregate-common", - "datafusion-functions-window-common", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-doc 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-functions-aggregate-common 53.1.0", + "datafusion-functions-window-common 53.1.0", + "datafusion-physical-expr-common 53.1.0", "indexmap 2.14.0", "itertools 0.14.0", "paste", + "serde_json", + "sqlparser 0.61.0", +] + +[[package]] +name = "datafusion-expr" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6932f4d71eed9c8d9341476a2b845aadfabde5495d08dbcd8fc23881f49fa7a0" +dependencies = [ + "arrow", + "arrow-schema", + "async-trait", + "chrono", + "datafusion-common 54.0.0", + "datafusion-doc 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-functions-aggregate-common 54.0.0", + "datafusion-functions-window-common 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "indexmap 2.14.0", + "itertools 0.14.0", "recursive", "serde_json", - "sqlparser", + "sqlparser 0.62.0", ] [[package]] @@ -2351,12 +2562,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d7c3adf3db8bf61e92eb90cb659c8e8b734593a8f7c8e12a843c7ddba24b87e" dependencies = [ "arrow", - "datafusion-common", + "datafusion-common 53.1.0", "indexmap 2.14.0", "itertools 0.14.0", "paste", ] +[[package]] +name = "datafusion-expr-common" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0225491839a31b1f7d2cb8092c2d50792e2fe1c1724e4e6d08e011f5feaf4ed2" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "indexmap 2.14.0", + "itertools 0.14.0", +] + [[package]] name = "datafusion-functions" version = "53.1.0" @@ -2370,16 +2593,16 @@ dependencies = [ "blake3", "chrono", "chrono-tz", - "datafusion-common", - "datafusion-doc", - "datafusion-execution", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-macros", + "datafusion-common 53.1.0", + "datafusion-doc 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-macros 53.1.0", "hex", "itertools 0.14.0", "log", - "md-5", + "md-5 0.10.6", "memchr", "num-traits", "rand 0.9.4", @@ -2389,6 +2612,38 @@ dependencies = [ "uuid", ] +[[package]] +name = "datafusion-functions" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14872c47bfc3d21e53ec82f57074e6987a15941c1e2f43cde4ac6ae2746634e3" +dependencies = [ + "arrow", + "arrow-buffer", + "base64", + "blake2", + "blake3", + "chrono", + "chrono-tz", + "datafusion-common 54.0.0", + "datafusion-doc 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-macros 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "hex", + "itertools 0.14.0", + "log", + "md-5 0.11.0", + "memchr", + "num-traits", + "rand 0.9.4", + "regex", + "sha2 0.11.0", + "uuid", +] + [[package]] name = "datafusion-functions-aggregate" version = "53.1.0" @@ -2397,20 +2652,41 @@ checksum = "00aa6217e56098ba84e0a338176fe52f0a84cca398021512c6c8c5eff806d0ad" dependencies = [ "ahash 0.8.12", "arrow", - "datafusion-common", - "datafusion-doc", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions-aggregate-common", - "datafusion-macros", - "datafusion-physical-expr", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-doc 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-functions-aggregate-common 53.1.0", + "datafusion-macros 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", "half", "log", "num-traits", "paste", ] +[[package]] +name = "datafusion-functions-aggregate" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a2ca14e1b609be21e657e2d3130b2f446456b08393b377bb721a33952d2e09" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "datafusion-doc 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions-aggregate-common 54.0.0", + "datafusion-macros 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "foldhash 0.2.0", + "half", + "log", + "num-traits", +] + [[package]] name = "datafusion-functions-aggregate-common" version = "53.1.0" @@ -2419,9 +2695,21 @@ checksum = "b511250349407db7c43832ab2de63f5557b19a20dfd236b39ca2c04468b50d47" dependencies = [ "ahash 0.8.12", "arrow", - "datafusion-common", - "datafusion-expr-common", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-physical-expr-common 53.1.0", +] + +[[package]] +name = "datafusion-functions-aggregate-common" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ece74ba09092d2ef9c9b54a38445450aea292a1f8b04faf531936b723a24b3c" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-physical-expr-common 54.0.0", ] [[package]] @@ -2432,16 +2720,16 @@ checksum = "ef13a858e20d50f0a9bb5e96e7ac82b4e7597f247515bccca4fdd2992df0212a" dependencies = [ "arrow", "arrow-ord", - "datafusion-common", - "datafusion-doc", - "datafusion-execution", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-functions", - "datafusion-functions-aggregate", - "datafusion-functions-aggregate-common", - "datafusion-macros", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-doc 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-functions 53.1.0", + "datafusion-functions-aggregate 53.1.0", + "datafusion-functions-aggregate-common 53.1.0", + "datafusion-macros 53.1.0", + "datafusion-physical-expr-common 53.1.0", "hashbrown 0.16.1", "itertools 0.14.0", "itoa", @@ -2449,6 +2737,31 @@ dependencies = [ "paste", ] +[[package]] +name = "datafusion-functions-nested" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3e3f9ee8ca59bf70518802107de6f1b88a9509efdc629fadc5de9d6b2d5ef5" +dependencies = [ + "arrow", + "arrow-ord", + "datafusion-common 54.0.0", + "datafusion-doc 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-functions 54.0.0", + "datafusion-functions-aggregate 54.0.0", + "datafusion-functions-aggregate-common 54.0.0", + "datafusion-macros 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "hashbrown 0.17.1", + "itertools 0.14.0", + "itoa", + "log", + "memchr", +] + [[package]] name = "datafusion-functions-table" version = "53.1.0" @@ -2457,14 +2770,30 @@ checksum = "72b40d3f5bbb3905f9ccb1ce9485a9595c77b69758a7c24d3ba79e334ff51e7e" dependencies = [ "arrow", "async-trait", - "datafusion-catalog", - "datafusion-common", - "datafusion-expr", - "datafusion-physical-plan", + "datafusion-catalog 53.1.0", + "datafusion-common 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-plan 53.1.0", "parking_lot", "paste", ] +[[package]] +name = "datafusion-functions-table" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89161dffc22cf2b50f9f4b1bee83b5221d3b4ed7c2e37fd7aa2b22a5297b3a26" +dependencies = [ + "arrow", + "async-trait", + "datafusion-catalog 54.0.0", + "datafusion-common 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-plan 54.0.0", + "parking_lot", +] + [[package]] name = "datafusion-functions-window" version = "53.1.0" @@ -2472,25 +2801,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e88ec9d57c9b685d02f58bfee7be62d72610430ddcedb82a08e5d9925dbfb6" dependencies = [ "arrow", - "datafusion-common", - "datafusion-doc", - "datafusion-expr", - "datafusion-functions-window-common", - "datafusion-macros", - "datafusion-physical-expr", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-doc 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-functions-window-common 53.1.0", + "datafusion-macros 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", "log", "paste", ] +[[package]] +name = "datafusion-functions-window" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7339345b226b3874037708bf5023ba1c2de705128f8457a095aae5ae9cb9c78" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "datafusion-doc 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions-window-common 54.0.0", + "datafusion-macros 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "log", +] + [[package]] name = "datafusion-functions-window-common" version = "53.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8307bb93519b1a91913723a1130cfafeee3f72200d870d88e91a6fc5470ede5c" dependencies = [ - "datafusion-common", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-physical-expr-common 53.1.0", +] + +[[package]] +name = "datafusion-functions-window-common" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa84836dc2392df6f43d6a29d37fb56a8ebdc8b3f4e10ae8dc15861fd20278fb" +dependencies = [ + "datafusion-common 54.0.0", + "datafusion-physical-expr-common 54.0.0", ] [[package]] @@ -2499,7 +2855,18 @@ version = "53.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e367e6a71051d0ebdd29b2f85d12059b38b1d1f172c6906e80016da662226bd" dependencies = [ - "datafusion-doc", + "datafusion-doc 53.1.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "datafusion-macros" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587164e03ad68732aa9e7bfe5686e3f25970d4c64fd4bd80790749840892dae5" +dependencies = [ + "datafusion-doc 54.0.0", "quote", "syn 2.0.117", ] @@ -2512,10 +2879,29 @@ checksum = "e929015451a67f77d9d8b727b2bf3a40c4445fdef6cdc53281d7d97c76888ace" dependencies = [ "arrow", "chrono", - "datafusion-common", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-physical-expr", + "datafusion-common 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-physical-expr 53.1.0", + "indexmap 2.14.0", + "itertools 0.14.0", + "log", + "regex", + "regex-syntax", +] + +[[package]] +name = "datafusion-optimizer" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77f20e8cf9e8654d92f4c16b24c487353ee5bf153ffc12d5772cd399ab8cd281" +dependencies = [ + "arrow", + "chrono", + "datafusion-common 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-physical-expr 54.0.0", "indexmap 2.14.0", "itertools 0.14.0", "log", @@ -2532,11 +2918,11 @@ checksum = "4b1e68aba7a4b350401cfdf25a3d6f989ad898a7410164afe9ca52080244cb59" dependencies = [ "ahash 0.8.12", "arrow", - "datafusion-common", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-functions-aggregate-common", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-functions-aggregate-common 53.1.0", + "datafusion-physical-expr-common 53.1.0", "half", "hashbrown 0.16.1", "indexmap 2.14.0", @@ -2544,6 +2930,27 @@ dependencies = [ "parking_lot", "paste", "petgraph", + "tokio", +] + +[[package]] +name = "datafusion-physical-expr" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f015a4a82f6f7ff7e1d8d4bf3870a936752fa38b17705dfcc14adef95aa8922c" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-functions-aggregate-common 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "half", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "parking_lot", + "petgraph", "recursive", "tokio", ] @@ -2555,11 +2962,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea22315f33cf2e0adc104e8ec42e285f6ed93998d565c65e82fec6a9ee9f9db4" dependencies = [ "arrow", - "datafusion-common", - "datafusion-expr", - "datafusion-functions", - "datafusion-physical-expr", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-functions 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "itertools 0.14.0", +] + +[[package]] +name = "datafusion-physical-expr-adapter" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e6ffff8acdfe54e0ea15ccf38115c4a9184433b0439f42907637928d00a235" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", "itertools 0.14.0", ] @@ -2572,14 +2994,31 @@ dependencies = [ "ahash 0.8.12", "arrow", "chrono", - "datafusion-common", - "datafusion-expr-common", + "datafusion-common 53.1.0", + "datafusion-expr-common 53.1.0", "hashbrown 0.16.1", "indexmap 2.14.0", "itertools 0.14.0", "parking_lot", ] +[[package]] +name = "datafusion-physical-expr-common" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7967a3e171c6a4bf09474b3f7a14f1a3db13ed1714ba12156f33fcce2bba54e8" +dependencies = [ + "arrow", + "chrono", + "datafusion-common 54.0.0", + "datafusion-expr-common 54.0.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "parking_lot", + "pin-project", +] + [[package]] name = "datafusion-physical-optimizer" version = "53.1.0" @@ -2587,14 +3026,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cb13397809a425918f608dfe8653f332015a3e330004ab191b4404187238b95" dependencies = [ "arrow", - "datafusion-common", - "datafusion-execution", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-pruning", + "datafusion-common 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-plan 53.1.0", + "datafusion-pruning 53.1.0", + "itertools 0.14.0", +] + +[[package]] +name = "datafusion-physical-optimizer" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ff803e2a96054cb6d83f35f9e60fd4f42eac515e1932bd1b2dbc91d5fcbf36" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-pruning 54.0.0", "itertools 0.14.0", "recursive", ] @@ -2610,15 +3067,15 @@ dependencies = [ "arrow-ord", "arrow-schema", "async-trait", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions", - "datafusion-functions-aggregate-common", - "datafusion-functions-window-common", - "datafusion-physical-expr", - "datafusion-physical-expr-common", + "datafusion-common 53.1.0", + "datafusion-common-runtime 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-functions 53.1.0", + "datafusion-functions-aggregate-common 53.1.0", + "datafusion-functions-window-common 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", "futures", "half", "hashbrown 0.16.1", @@ -2631,6 +3088,39 @@ dependencies = [ "tokio", ] +[[package]] +name = "datafusion-physical-plan" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "776ee54d47d15bdb126452f9ca17b03761e3b004682914beaedd3f86eb507fbc" +dependencies = [ + "arrow", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-schema", + "async-trait", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions 54.0.0", + "datafusion-functions-aggregate-common 54.0.0", + "datafusion-functions-window-common 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "futures", + "half", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itertools 0.14.0", + "log", + "num-traits", + "parking_lot", + "pin-project-lite", + "tokio", +] + [[package]] name = "datafusion-pruning" version = "53.1.0" @@ -2638,16 +3128,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac8c76860e355616555081cab5968cec1af7a80701ff374510860bcd567e365a" dependencies = [ "arrow", - "datafusion-common", - "datafusion-datasource", - "datafusion-expr-common", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", + "datafusion-common 53.1.0", + "datafusion-datasource 53.1.0", + "datafusion-expr-common 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-expr-common 53.1.0", + "datafusion-physical-plan 53.1.0", "itertools 0.14.0", "log", ] +[[package]] +name = "datafusion-pruning" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fb9e5774660aa69c3ba93c610f175f75b65cb8c3776edb3626de8f3a4f4ee3" +dependencies = [ + "arrow", + "datafusion-common 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-expr-common 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "log", +] + [[package]] name = "datafusion-session" version = "53.1.0" @@ -2655,37 +3161,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5412111aa48e2424ba926112e192f7a6b7e4ccb450145d25ce5ede9f19dc491e" dependencies = [ "async-trait", - "datafusion-common", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-plan", + "datafusion-common 53.1.0", + "datafusion-execution 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-plan 53.1.0", + "parking_lot", +] + +[[package]] +name = "datafusion-session" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15ce715fa2a61f4623cc234bcc14a3ef6a91f189128d5b14b468a6a17cdfc417" +dependencies = [ + "async-trait", + "datafusion-common 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-physical-plan 54.0.0", "parking_lot", ] [[package]] name = "datafusion-spark" -version = "53.1.0" +version = "54.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e059dcf8544da0d6598d0235be3cc29c209094a5976b2e4822e4a2cf91c2b5c5" +checksum = "390bb0bf37cb2b95ffd65eacd66f60df50793d1f94097799e416f39477a51957" dependencies = [ "arrow", "bigdecimal", "chrono", "crc32fast", - "datafusion", - "datafusion-catalog", - "datafusion-common", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions", - "datafusion-functions-aggregate", - "datafusion-functions-nested", + "datafusion 54.0.0", + "datafusion-catalog 54.0.0", + "datafusion-common 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions 54.0.0", + "datafusion-functions-aggregate 54.0.0", + "datafusion-functions-aggregate-common 54.0.0", + "datafusion-functions-nested 54.0.0", "log", + "num-traits", "percent-encoding", "rand 0.9.4", "serde_json", - "sha1 0.10.6", - "sha2 0.10.9", + "sha1", + "sha2 0.11.0", + "twox-hash", "url", ] @@ -2698,29 +3221,46 @@ dependencies = [ "arrow", "bigdecimal", "chrono", - "datafusion-common", - "datafusion-expr", - "datafusion-functions-nested", + "datafusion-common 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-functions-nested 53.1.0", + "indexmap 2.14.0", + "log", + "regex", + "sqlparser 0.61.0", +] + +[[package]] +name = "datafusion-sql" +version = "54.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6094ad36a3ed6d7ac87b20b479b2d0b118250f66cf997603829fdc65b44a7099" +dependencies = [ + "arrow", + "bigdecimal", + "chrono", + "datafusion-common 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions-nested 54.0.0", "indexmap 2.14.0", "log", "recursive", "regex", - "sqlparser", + "sqlparser 0.62.0", ] [[package]] name = "datafusion-sqllogictest" -version = "53.1.0" +version = "54.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e5a4a7a49143a68936992b6dbb0db44121c635e9992b2482817278f1e69c56" +checksum = "e0c08025966108056d3547d879c4d39e246277494f59ca12920f78187d95eea1" dependencies = [ "arrow", "async-trait", "bigdecimal", "clap", - "datafusion", + "datafusion 54.0.0", "datafusion-spark", - "datafusion-substrait", "futures", "half", "indicatif", @@ -2728,32 +3268,12 @@ dependencies = [ "log", "object_store 0.13.2", "sqllogictest", - "sqlparser", + "sqlparser 0.62.0", "tempfile", "thiserror 2.0.18", "tokio", ] -[[package]] -name = "datafusion-substrait" -version = "53.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98494539a5468979cc42d86c7bc5f0f8cb71ee5c742694c26fc34efdd29dd2e5" -dependencies = [ - "async-recursion", - "async-trait", - "chrono", - "datafusion", - "half", - "itertools 0.14.0", - "object_store 0.13.2", - "pbjson-types", - "prost 0.14.3", - "substrait", - "tokio", - "url", -] - [[package]] name = "deepsize" version = "0.2.0" @@ -2790,17 +3310,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -2870,9 +3379,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -2905,25 +3414,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab23e69df104e2fd85ee63a533a22d2132ef5975dc6b36f9f3e5a7305e4a8ed7" -[[package]] -name = "duckdb" -version = "1.10502.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fdc796383b176dd5a45353fbb5e64583c0ee4da12cb62c9e510b785324b2488" -dependencies = [ - "arrow", - "cast", - "comfy-table", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libduckdb-sys", - "num", - "num-integer", - "rust_decimal", - "strum 0.27.2", -] - [[package]] name = "duckdb-bench" version = "0.1.0" @@ -3125,32 +3615,27 @@ dependencies = [ "ext-trait", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fast-float2" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastlanes" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "414cb755aee48ff7b0907995d2949c68c8c17900970076dff6a808e18e592d71" +checksum = "20c597e23b8ec8506f589d18bc701ca83a3def6086748f628ad23092e1dfe577" dependencies = [ "arrayref", "const_for", + "core_detect", "num-traits", "paste", "seq-macro", @@ -3272,9 +3757,9 @@ dependencies = [ [[package]] name = "fsst-rs" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf53d7c403a2b76873d4d66ba7d79c54bde2784cdaba6083f223d6e33270708" +checksum = "9b13ac798afc0d9194eb4efefef8b9332efbd80b43f302a968cb8cb23b9d5360" dependencies = [ "rustc-hash", ] @@ -3412,9 +3897,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", "cfg-if", @@ -3435,6 +3920,65 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo-traits" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7c353d12a704ccfab1ba8bfb1a7fe6cb18b665bf89d37f4f7890edcd260206" +dependencies = [ + "geo-types", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "serde", +] + +[[package]] +name = "geoarrow" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec42ac7fb4fdcd6982dab92d24faf436f18c36e47c3f813a33619a2728718a30" +dependencies = [ + "geoarrow-array", + "geoarrow-schema", +] + +[[package]] +name = "geoarrow-array" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafe7b7de3fab1a8b7099fd6a6434ca955fa65065f9c19f0f8a133693f3c2b0e" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "geo-traits", + "geoarrow-schema", + "num-traits", + "wkb", + "wkt", +] + +[[package]] +name = "geoarrow-schema" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4a7edb2a1d87024a93805332a9c8184a0354836271d42c0d18cf628a5e3cd0" +dependencies = [ + "arrow-schema", + "geo-traits", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "get_dir" version = "0.5.0" @@ -3607,15 +4151,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -3645,9 +4180,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -3682,12 +4217,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "humansize" version = "2.1.3" @@ -3714,9 +4243,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3726,7 +4255,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3748,7 +4276,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -4007,7 +4534,6 @@ checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ "console 0.16.3", "once_cell", - "regex", "similar 2.7.0", "tempfile", ] @@ -4319,11 +4845,11 @@ dependencies = [ "chrono", "crossbeam-skiplist", "dashmap", - "datafusion", - "datafusion-expr", - "datafusion-functions", - "datafusion-physical-expr", - "datafusion-physical-plan", + "datafusion 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-functions 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-physical-plan 53.1.0", "deepsize", "either", "futures", @@ -4346,7 +4872,7 @@ dependencies = [ "object_store 0.12.5", "permutation", "pin-project", - "prost 0.14.3", + "prost 0.14.4", "prost-build", "prost-types", "rand 0.9.4", @@ -4428,8 +4954,8 @@ dependencies = [ "byteorder", "bytes", "chrono", - "datafusion-common", - "datafusion-sql", + "datafusion-common 53.1.0", + "datafusion-sql 53.1.0", "deepsize", "futures", "itertools 0.13.0", @@ -4441,7 +4967,7 @@ dependencies = [ "num_cpus", "object_store 0.12.5", "pin-project", - "prost 0.14.3", + "prost 0.14.4", "rand 0.9.4", "roaring", "serde_json", @@ -4469,10 +4995,10 @@ dependencies = [ "arrow-select", "async-trait", "chrono", - "datafusion", - "datafusion-common", - "datafusion-functions", - "datafusion-physical-expr", + "datafusion 53.1.0", + "datafusion-common 53.1.0", + "datafusion-functions 53.1.0", + "datafusion-physical-expr 53.1.0", "futures", "jsonb", "lance-arrow", @@ -4480,7 +5006,7 @@ dependencies = [ "lance-datagen", "log", "pin-project", - "prost 0.14.3", + "prost 0.14.4", "prost-build", "snafu", "tokio", @@ -4534,7 +5060,7 @@ dependencies = [ "log", "lz4", "num-traits", - "prost 0.14.3", + "prost 0.14.4", "prost-build", "prost-types", "rand 0.9.4", @@ -4562,7 +5088,7 @@ dependencies = [ "async-trait", "byteorder", "bytes", - "datafusion-common", + "datafusion-common 53.1.0", "deepsize", "futures", "lance-arrow", @@ -4572,7 +5098,7 @@ dependencies = [ "log", "num-traits", "object_store 0.12.5", - "prost 0.14.3", + "prost 0.14.4", "prost-build", "prost-types", "snafu", @@ -4600,11 +5126,11 @@ dependencies = [ "bytes", "chrono", "crossbeam-queue", - "datafusion", - "datafusion-common", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-sql", + "datafusion 53.1.0", + "datafusion-common 53.1.0", + "datafusion-expr 53.1.0", + "datafusion-physical-expr 53.1.0", + "datafusion-sql 53.1.0", "deepsize", "dirs", "fst", @@ -4627,7 +5153,7 @@ dependencies = [ "ndarray", "num-traits", "object_store 0.12.5", - "prost 0.14.3", + "prost 0.14.4", "prost-build", "prost-types", "rand 0.9.4", @@ -4678,7 +5204,7 @@ dependencies = [ "object_store 0.12.5", "path_abs", "pin-project", - "prost 0.14.3", + "prost 0.14.4", "rand 0.9.4", "serde", "snafu", @@ -4758,7 +5284,7 @@ dependencies = [ "lance-io", "log", "object_store 0.12.5", - "prost 0.14.3", + "prost 0.14.4", "prost-build", "prost-types", "rand 0.9.4", @@ -4901,28 +5427,11 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libduckdb-sys" -version = "1.10502.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7401630ae2abcff642f7156294289e50f2d222e061c026ad797b01bf20c215" -dependencies = [ - "cc", - "flate2", - "pkg-config", - "reqwest 0.12.28", - "serde", - "serde_json", - "tar", - "vcpkg", - "zip 6.0.0", -] - [[package]] name = "libfuzzer-sys" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -4985,9 +5494,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -5022,12 +5531,6 @@ dependencies = [ "cc", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -5057,9 +5560,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "loom" @@ -5076,11 +5579,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.16.4" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -5119,9 +5622,9 @@ dependencies = [ [[package]] name = "lzma-rust2" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e9ceaec84b54518262de7cf06b8b43e83c808349960f1610b21b0bfc9640f20" +checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02" dependencies = [ "sha2 0.11.0", ] @@ -5151,12 +5654,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "matrixmultiply" version = "0.3.10" @@ -5171,37 +5668,23 @@ dependencies = [ ] [[package]] -name = "maud" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" -dependencies = [ - "axum-core", - "http", - "itoa", - "maud_macros", -] - -[[package]] -name = "maud_macros" -version = "0.27.0" +name = "md-5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", + "cfg-if", + "digest 0.10.7", ] [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -5262,9 +5745,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -5304,28 +5787,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "multiversion" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" -dependencies = [ - "multiversion-macros", - "target-features", -] - -[[package]] -name = "multiversion-macros" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "target-features", -] - [[package]] name = "ndarray" version = "0.16.1" @@ -5489,20 +5950,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -5511,7 +5958,6 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", - "serde", ] [[package]] @@ -5538,28 +5984,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -5596,6 +6020,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", @@ -5682,7 +6107,7 @@ dependencies = [ "humantime", "hyper", "itertools 0.14.0", - "md-5", + "md-5 0.10.6", "parking_lot", "percent-encoding", "quick-xml", @@ -5783,7 +6208,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost 0.14.3", + "prost 0.14.4", "reqwest 0.13.4", "thiserror 2.0.18", ] @@ -5796,7 +6221,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry", "opentelemetry_sdk", - "prost 0.14.3", + "prost 0.14.4", ] [[package]] @@ -5845,11 +6270,35 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "papergrid" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +checksum = "d0984e668274d34691bc2b262ef0d115de5fa9973bcdee7ae32213f93099153e" dependencies = [ "bytecount", "fnv", @@ -6014,43 +6463,6 @@ dependencies = [ "stfu8", ] -[[package]] -name = "pbjson" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898bac3fa00d0ba57a4e8289837e965baa2dee8c3749f3b11d45a64b4223d9c3" -dependencies = [ - "base64", - "serde", -] - -[[package]] -name = "pbjson-build" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af22d08a625a2213a78dbb0ffa253318c5c79ce3133d32d296655a7bdfb02095" -dependencies = [ - "heck", - "itertools 0.14.0", - "prost 0.14.3", - "prost-types", -] - -[[package]] -name = "pbjson-types" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e748e28374f10a330ee3bb9f29b828c0ac79831a32bab65015ad9b661ead526" -dependencies = [ - "bytes", - "chrono", - "pbjson", - "pbjson-build", - "prost 0.14.3", - "prost-build", - "serde", -] - [[package]] name = "pbkdf2" version = "0.13.0" @@ -6207,7 +6619,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -6323,18 +6735,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", -] - [[package]] name = "prost" version = "0.12.6" @@ -6347,19 +6747,19 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive 0.14.3", + "prost-derive 0.14.4", ] [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", "itertools 0.14.0", @@ -6367,7 +6767,7 @@ dependencies = [ "multimap", "petgraph", "prettyplease", - "prost 0.14.3", + "prost 0.14.4", "prost-types", "regex", "syn 2.0.117", @@ -6389,9 +6789,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6402,11 +6802,11 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost 0.14.3", + "prost 0.14.4", ] [[package]] @@ -6557,12 +6957,6 @@ dependencies = [ "url", ] -[[package]] -name = "quad-rand" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" - [[package]] name = "quick-xml" version = "0.39.4" @@ -6626,7 +7020,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6806,30 +7200,34 @@ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" dependencies = [ "bitflags", "compact_str", - "hashbrown 0.16.1", + "critical-section", + "hashbrown 0.17.1", "indoc", "itertools 0.14.0", "kasuari", "lru", - "strum 0.27.2", + "palette", + "serde", + "strum 0.28.0", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -6838,30 +7236,31 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" dependencies = [ "cfg-if", - "crossterm 0.29.0", + "crossterm", "instability", "ratatui-core", ] [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" dependencies = [ "bitflags", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indoc", "instability", "itertools 0.14.0", "line-clipping", "ratatui-core", - "strum 0.27.2", + "serde", + "strum 0.28.0", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -7004,16 +7403,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "regress" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" -dependencies = [ - "hashbrown 0.16.1", - "memchr", -] - [[package]] name = "relative-path" version = "1.9.3" @@ -7038,7 +7427,6 @@ dependencies = [ "base64", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", @@ -7073,7 +7461,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots", ] [[package]] @@ -7104,8 +7491,6 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", - "serde", - "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -7261,19 +7646,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -7283,7 +7655,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -7304,9 +7676,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -7393,18 +7765,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "0.9.0" @@ -7429,18 +7789,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -7514,16 +7862,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -7544,17 +7882,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "serde_json" version = "1.0.150" @@ -7568,17 +7895,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -7608,18 +7924,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_tokenstream" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -7634,9 +7938,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -7654,9 +7958,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", @@ -7664,19 +7968,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serial_test" version = "3.5.0" @@ -7702,17 +7993,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -7901,18 +8181,18 @@ dependencies = [ [[package]] name = "snafu" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6" +checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" +checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" dependencies = [ "heck", "proc-macro2", @@ -7928,9 +8208,9 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -7950,7 +8230,7 @@ dependencies = [ "humantime", "itertools 0.13.0", "libtest-mimic", - "md-5", + "md-5 0.10.6", "owo-colors", "rand 0.8.6", "regex", @@ -7966,6 +8246,16 @@ name = "sqlparser" version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbf5ea8d4d7c808e1af1cbabebca9a2abe603bcefc22294c5b95018d53200cb7" +dependencies = [ + "log", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" dependencies = [ "log", "recursive", @@ -8045,15 +8335,6 @@ dependencies = [ "strum_macros 0.26.4", ] -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - [[package]] name = "strum" version = "0.28.0" @@ -8076,18 +8357,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "strum_macros" version = "0.28.0" @@ -8110,31 +8379,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "substrait" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62fc4b483a129b9772ccb9c3f7945a472112fdd9140da87f8a4e7f1d44e045d0" -dependencies = [ - "heck", - "pbjson", - "pbjson-build", - "pbjson-types", - "prettyplease", - "prost 0.14.3", - "prost-build", - "prost-types", - "regress", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "serde_yaml", - "syn 2.0.117", - "typify", - "walkdir", -] - [[package]] name = "subtle" version = "2.6.1" @@ -8220,9 +8464,9 @@ dependencies = [ [[package]] name = "tabled" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +checksum = "b5dc662e6da844ad6e428ad16b57967c9d33c82e16bb1c258326c0c078605dff" dependencies = [ "papergrid", "testing_table", @@ -8269,12 +8513,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "target-features" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" - [[package]] name = "target-lexicon" version = "0.13.5" @@ -8299,7 +8537,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -8318,7 +8556,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -8630,9 +8868,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -8668,7 +8906,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -8691,7 +8928,6 @@ dependencies = [ "tower", "tower-layer", "tower-service", - "tracing", "url", ] @@ -8829,62 +9065,15 @@ dependencies = [ [[package]] name = "typed-path" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "typify" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5bcc6f62eb1fa8aa4098f39b29f93dcb914e17158b76c50360911257aa629" -dependencies = [ - "typify-impl", - "typify-macro", -] - -[[package]] -name = "typify-impl" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1eb359f7ffa4f9ebe947fa11a1b2da054564502968db5f317b7e37693cb2240" -dependencies = [ - "heck", - "log", - "proc-macro2", - "quote", - "regress", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "syn 2.0.117", - "thiserror 2.0.18", - "unicode-ident", -] +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] -name = "typify-macro" -version = "0.5.0" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911c32f3c8514b048c1b228361bebb5e6d73aeec01696e8cc0e82e2ffef8ab7a" -dependencies = [ - "proc-macro2", - "quote", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "serde_tokenstream", - "syn 2.0.117", - "typify-impl", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uint" @@ -8922,9 +9111,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -8961,12 +9150,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -9037,37 +9220,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "vector-search-bench" -version = "0.1.0" -dependencies = [ - "anyhow", - "arrow-array", - "arrow-buffer", - "arrow-schema", - "clap", - "futures", - "indicatif", - "parquet", - "rand 0.10.1", - "rand_distr 0.6.0", - "serde", - "tabled", - "tempfile", - "tokio", - "tracing", - "vortex", - "vortex-bench", - "vortex-btrblocks", - "vortex-tensor", -] - [[package]] name = "version_check" version = "0.9.5" @@ -9095,7 +9247,6 @@ dependencies = [ "vortex", "vortex-alp", "vortex-array", - "vortex-bench", "vortex-btrblocks", "vortex-buffer", "vortex-bytebool", @@ -9131,7 +9282,7 @@ dependencies = [ "codspeed-divan-compat", "itertools 0.14.0", "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rand 0.10.1", "rstest", "rustc-hash", @@ -9175,14 +9326,13 @@ dependencies = [ "inventory", "itertools 0.14.0", "jiff", - "multiversion", "num-traits", "num_enum", "parking_lot", "paste", "pin-project-lite", "primitive-types", - "prost 0.14.3", + "prost 0.14.4", "rand 0.10.1", "rand_distr 0.6.0", "rstest", @@ -9196,6 +9346,7 @@ dependencies = [ "static_assertions", "tabled", "termtree", + "test-with", "tracing", "uuid", "vortex-array", @@ -9227,7 +9378,6 @@ dependencies = [ "arrow-schema", "arrow-select", "async-trait", - "bytes", "bzip2", "clap", "futures", @@ -9264,60 +9414,6 @@ dependencies = [ "vortex-tensor", ] -[[package]] -name = "vortex-bench-migrate" -version = "0.1.0-alpha.0" -dependencies = [ - "anyhow", - "arrow-array", - "arrow-buffer", - "arrow-schema", - "clap", - "duckdb", - "flate2", - "reqwest 0.13.4", - "rstest", - "serde", - "serde_json", - "tempfile", - "tokio", - "tracing", - "tracing-subscriber", - "vortex-bench-server", - "vortex-utils", -] - -[[package]] -name = "vortex-bench-server" -version = "0.1.0-alpha.0" -dependencies = [ - "anyhow", - "axum", - "base64", - "brotli", - "bytes", - "dashmap", - "duckdb", - "flate2", - "futures", - "insta", - "maud", - "parking_lot", - "reqwest 0.13.4", - "serde", - "serde_json", - "subtle", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "twox-hash", - "vortex-utils", -] - [[package]] name = "vortex-btrblocks" version = "0.1.0" @@ -9328,9 +9424,7 @@ dependencies = [ "pco", "rand 0.10.1", "rstest", - "rustc-hash", "test-with", - "tracing", "vortex-alp", "vortex-array", "vortex-buffer", @@ -9340,14 +9434,12 @@ dependencies = [ "vortex-error", "vortex-fastlanes", "vortex-fsst", - "vortex-mask", "vortex-onpair", "vortex-pco", "vortex-runend", "vortex-sequence", "vortex-session", "vortex-sparse", - "vortex-tensor", "vortex-utils", "vortex-zigzag", "vortex-zstd", @@ -9460,7 +9552,7 @@ dependencies = [ "num-traits", "object_store 0.13.2", "parking_lot", - "prost 0.14.3", + "prost 0.14.4", "rstest", "tokio", "tracing", @@ -9474,6 +9566,19 @@ dependencies = [ "vortex-nvcomp", ] +[[package]] +name = "vortex-cuda-ffi" +version = "0.1.0" +dependencies = [ + "arrow-schema", + "futures", + "vortex", + "vortex-array", + "vortex-cuda", + "vortex-cuda-macros", + "vortex-ffi", +] + [[package]] name = "vortex-cuda-macros" version = "0.1.0" @@ -9504,19 +9609,19 @@ dependencies = [ "anyhow", "arrow-schema", "async-trait", - "datafusion", - "datafusion-catalog", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-pruning", + "datafusion 54.0.0", + "datafusion-catalog 54.0.0", + "datafusion-common 54.0.0", + "datafusion-common-runtime 54.0.0", + "datafusion-datasource 54.0.0", + "datafusion-execution 54.0.0", + "datafusion-expr 54.0.0", + "datafusion-functions 54.0.0", + "datafusion-physical-expr 54.0.0", + "datafusion-physical-expr-adapter 54.0.0", + "datafusion-physical-expr-common 54.0.0", + "datafusion-physical-plan 54.0.0", + "datafusion-pruning 54.0.0", "futures", "insta", "itertools 0.14.0", @@ -9537,7 +9642,7 @@ name = "vortex-datetime-parts" version = "0.1.0" dependencies = [ "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rstest", "vortex-array", "vortex-buffer", @@ -9551,7 +9656,7 @@ name = "vortex-decimal-byte-parts" version = "0.1.0" dependencies = [ "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rstest", "vortex-array", "vortex-buffer", @@ -9573,6 +9678,7 @@ dependencies = [ "cc", "custom-labels", "futures", + "geo-types", "itertools 0.14.0", "jiff", "kanal", @@ -9589,10 +9695,12 @@ dependencies = [ "url", "vortex", "vortex-array", + "vortex-geo", "vortex-runend", "vortex-sequence", "vortex-utils", - "zip 8.6.0", + "wkb", + "zip", ] [[package]] @@ -9603,7 +9711,7 @@ dependencies = [ "flatbuffers", "jiff", "object_store 0.13.2", - "prost 0.14.3", + "prost 0.14.4", "serial_test", "temp-env", "tokio", @@ -9618,7 +9726,7 @@ dependencies = [ "itertools 0.14.0", "lending-iterator", "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rand 0.10.1", "rstest", "vortex-alp", @@ -9643,7 +9751,7 @@ dependencies = [ "mimalloc", "object_store 0.13.2", "paste", - "prost 0.14.3", + "prost 0.14.4", "rand 0.10.1", "tempfile", "tracing", @@ -9713,7 +9821,7 @@ version = "0.1.0" dependencies = [ "codspeed-divan-compat", "fsst-rs", - "prost 0.14.3", + "prost 0.14.4", "rand 0.10.1", "rstest", "test-with", @@ -9750,6 +9858,22 @@ dependencies = [ "vortex-utils", ] +[[package]] +name = "vortex-geo" +version = "0.1.0" +dependencies = [ + "arrow-array", + "arrow-schema", + "geo-traits", + "geo-types", + "geoarrow", + "prost 0.14.4", + "vortex-array", + "vortex-error", + "vortex-session", + "wkb", +] + [[package]] name = "vortex-io" version = "0.1.0" @@ -9811,11 +9935,24 @@ dependencies = [ "jni", "object_store 0.13.2", "parking_lot", + "roaring", "thiserror 2.0.18", "tracing", "tracing-subscriber", "url", "vortex", + "vortex-parquet-variant", +] + +[[package]] +name = "vortex-json" +version = "0.1.0" +dependencies = [ + "arrow-array", + "arrow-schema", + "vortex-array", + "vortex-error", + "vortex-session", ] [[package]] @@ -9838,7 +9975,7 @@ dependencies = [ "parking_lot", "paste", "pin-project-lite", - "prost 0.14.3", + "prost 0.14.4", "rstest", "rustc-hash", "sketches-ddsketch", @@ -9865,7 +10002,6 @@ dependencies = [ name = "vortex-mask" version = "0.1.0" dependencies = [ - "arrow-buffer", "codspeed-divan-compat", "itertools 0.14.0", "rstest", @@ -9902,7 +10038,7 @@ dependencies = [ "memchr", "num-traits", "onpair", - "prost 0.14.3", + "prost 0.14.4", "rstest", "vortex-array", "vortex-buffer", @@ -9921,7 +10057,7 @@ dependencies = [ "chrono", "parquet-variant", "parquet-variant-compute", - "prost 0.14.3", + "prost 0.14.4", "rstest", "tokio", "vortex-array", @@ -9940,7 +10076,7 @@ name = "vortex-pco" version = "0.1.0" dependencies = [ "pco", - "prost 0.14.3", + "prost 0.14.4", "rstest", "vortex-array", "vortex-buffer", @@ -9953,7 +10089,7 @@ dependencies = [ name = "vortex-proto" version = "0.1.0" dependencies = [ - "prost 0.14.3", + "prost 0.14.4", "prost-types", ] @@ -9981,6 +10117,26 @@ dependencies = [ "vortex-tui", ] +[[package]] +name = "vortex-row" +version = "0.1.0" +dependencies = [ + "arrow-array", + "arrow-row", + "arrow-schema", + "bytes", + "codspeed-divan-compat", + "mimalloc", + "rand 0.10.1", + "rstest", + "smallvec", + "vortex-array", + "vortex-buffer", + "vortex-error", + "vortex-mask", + "vortex-session", +] + [[package]] name = "vortex-runend" version = "0.1.0" @@ -9991,7 +10147,7 @@ dependencies = [ "codspeed-divan-compat", "itertools 0.14.0", "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rand 0.10.1", "rstest", "vortex-array", @@ -10022,7 +10178,7 @@ version = "0.1.0" dependencies = [ "itertools 0.14.0", "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rstest", "smallvec", "vortex-array", @@ -10051,7 +10207,7 @@ dependencies = [ "codspeed-divan-compat", "itertools 0.14.0", "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rstest", "vortex-array", "vortex-buffer", @@ -10068,7 +10224,7 @@ dependencies = [ "async-trait", "bigdecimal", "clap", - "datafusion", + "datafusion 54.0.0", "datafusion-sqllogictest", "futures", "indicatif", @@ -10092,7 +10248,7 @@ dependencies = [ "itertools 0.14.0", "mimalloc", "num-traits", - "prost 0.14.3", + "prost 0.14.4", "rand 0.10.1", "rand_distr 0.6.0", "rstest", @@ -10101,7 +10257,6 @@ dependencies = [ "vortex-buffer", "vortex-compressor", "vortex-error", - "vortex-fastlanes", "vortex-session", "vortex-utils", ] @@ -10126,8 +10281,8 @@ dependencies = [ "arrow-schema", "clap", "console_error_panic_hook", - "crossterm 0.29.0", - "datafusion", + "crossterm", + "datafusion 54.0.0", "env_logger", "flatbuffers", "futures", @@ -10150,35 +10305,12 @@ dependencies = [ "web-sys", ] -[[package]] -name = "vortex-turboquant" -version = "0.1.0" -dependencies = [ - "codspeed-divan-compat", - "half", - "num-traits", - "prost 0.14.3", - "rand 0.10.1", - "rstest", - "vortex-array", - "vortex-buffer", - "vortex-error", - "vortex-file", - "vortex-io", - "vortex-layout", - "vortex-mask", - "vortex-session", - "vortex-tensor", - "vortex-utils", -] - [[package]] name = "vortex-utils" version = "0.1.0" dependencies = [ "dashmap", "hashbrown 0.17.1", - "parking_lot", "vortex-error", ] @@ -10219,7 +10351,7 @@ version = "0.1.0" dependencies = [ "codspeed-divan-compat", "itertools 0.14.0", - "prost 0.14.3", + "prost 0.14.4", "rstest", "vortex-array", "vortex-buffer", @@ -10281,7 +10413,6 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", - "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -10417,20 +10548,11 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] @@ -10584,7 +10706,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -10593,7 +10715,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -10611,14 +10742,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -10636,48 +10784,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -10787,6 +10983,31 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wkb" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a120b336c7ad17749026d50427c23d838ecb50cd64aaea6254b5030152f890a9" +dependencies = [ + "byteorder", + "geo-traits", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "wkt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb2b923ccc882312e559ffaa832a055ba9d1ac0cc8e86b3e25453247e4b81d7" +dependencies = [ + "geo-traits", + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + [[package]] name = "writeable" version = "0.6.3" @@ -10809,7 +11030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -10852,9 +11073,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10875,18 +11096,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -10962,20 +11183,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "zip" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" -dependencies = [ - "arbitrary", - "crc32fast", - "flate2", - "indexmap 2.14.0", - "memchr", - "zopfli", -] - [[package]] name = "zip" version = "8.6.0" @@ -10995,7 +11202,7 @@ dependencies = [ "memchr", "pbkdf2", "ppmd-rust", - "sha1 0.11.0", + "sha1", "time", "typed-path", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 9700d8d78ed..a587ef1db16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,9 @@ members = [ "vortex-io", "vortex-proto", "vortex-array", + "vortex-row", "vortex-tensor", - "vortex-turboquant", + "vortex-json", "vortex-compressor", "vortex-btrblocks", "vortex-layout", @@ -26,6 +27,7 @@ members = [ "vortex-duckdb", "vortex-cuda", "vortex-cuda/cub", + "vortex-cuda/ffi", "vortex-cuda/gpu-scan-cli", "vortex-cuda/macros", "vortex-cuda/nvcomp", @@ -62,10 +64,7 @@ members = [ "benchmarks/datafusion-bench", "benchmarks/duckdb-bench", "benchmarks/random-access-bench", - "benchmarks/vector-search-bench", - # Benchmarks website v3 (alpha) - leaf binary, not part of vortex-* API - "benchmarks-website/server", - "benchmarks-website/migrate", + "vortex-geo", ] exclude = ["java/testfiles", "wasm-test"] resolver = "2" @@ -97,16 +96,17 @@ anyhow = "1.0.97" arbitrary = "1.3.2" arc-swap = "1.9" arcref = "0.2.0" -arrow-arith = "58" -arrow-array = "58" -arrow-buffer = "58" -arrow-cast = "58" -arrow-data = "58" -arrow-ipc = "58" -arrow-ord = "58" -arrow-schema = "58" -arrow-select = "58" -arrow-string = "58" +arrow-arith = "58.3" +arrow-array = "58.3" +arrow-buffer = "58.3" +arrow-cast = "58.3" +arrow-data = "58.3" +arrow-ipc = "58.3" +arrow-ord = "58.3" +arrow-row = "58.3" +arrow-schema = "58.3" +arrow-select = "58.3" +arrow-string = "58.3" async-fs = "2.2.0" async-lock = "3.4" async-stream = "0.3.6" @@ -134,34 +134,37 @@ cudarc = { version = "0.19.0", features = [ ] } custom-labels = "0.4.4" dashmap = "6.1.0" -datafusion = { version = "53", default-features = false, features = ["sql"] } -datafusion-catalog = { version = "53" } -datafusion-common = { version = "53" } -datafusion-common-runtime = { version = "53" } -datafusion-datasource = { version = "53", default-features = false } -datafusion-execution = { version = "53" } -datafusion-expr = { version = "53" } -datafusion-functions = { version = "53" } -datafusion-physical-expr = { version = "53" } -datafusion-physical-expr-adapter = { version = "53" } -datafusion-physical-expr-common = { version = "53" } -datafusion-physical-plan = { version = "53" } -datafusion-pruning = { version = "53" } -datafusion-sqllogictest = { version = "53" } +datafusion = { version = "54", default-features = false, features = ["sql"] } +datafusion-catalog = { version = "54" } +datafusion-common = { version = "54" } +datafusion-common-runtime = { version = "54" } +datafusion-datasource = { version = "54", default-features = false } +datafusion-execution = { version = "54" } +datafusion-expr = { version = "54" } +datafusion-functions = { version = "54" } +datafusion-physical-expr = { version = "54" } +datafusion-physical-expr-adapter = { version = "54" } +datafusion-physical-expr-common = { version = "54" } +datafusion-physical-plan = { version = "54" } +datafusion-pruning = { version = "54" } +datafusion-sqllogictest = { version = "54" } dirs = "6.0.0" divan = { package = "codspeed-divan-compat", version = "4.0.4" } enum-iterator = "2.0.0" env_logger = "0.11" -fastlanes = "0.5" +fastlanes = "0.5.1" flatbuffers = "25.2.10" -fsst-rs = "0.5.5" +fsst-rs = "0.5.11" futures = { version = "0.3.31", default-features = false } fuzzy-matcher = "0.3" +geo-traits = "0.3.0" +geo-types = "0.7.19" +geoarrow = "0.8.0" get_dir = "0.5.0" glob = "0.3.2" goldenfile = "1" half = { version = "2.7.1", features = ["std", "num-traits"] } -hashbrown = "0.17.0" +hashbrown = "0.17.1" humansize = "2.1.3" indicatif = "0.18.0" insta = "1.43" @@ -187,7 +190,7 @@ noodles-bgzf = "0.47.0" noodles-vcf = { version = "0.88.0", features = ["async"] } num-traits = "0.2.19" num_enum = { version = "0.7.3", default-features = false } -object_store = { version = "0.13.1", default-features = false } +object_store = { version = "0.13.2", default-features = false } once_cell = "1.21" oneshot = { version = "0.2.0", features = ["async"] } onpair = { version = "0.0.4" } @@ -195,9 +198,9 @@ opentelemetry = "0.32.0" opentelemetry-otlp = "0.32.0" opentelemetry_sdk = "0.32.0" parking_lot = { version = "0.12.3", features = ["nightly"] } -parquet = "58" -parquet-variant = "58" -parquet-variant-compute = "58" +parquet = "58.3" +parquet-variant = "58.3" +parquet-variant-compute = "58.3" paste = "1.0.15" pco = "1.0.1" pin-project-lite = "0.2.15" @@ -226,7 +229,7 @@ reqwest = { version = "0.13.0", features = [ roaring = "0.11.0" rstest = "0.26.1" rstest_reuse = "0.7.0" -rustc-hash = "2.1" +rustc-hash = "2.1.1" serde = "1.0.220" serde_json = "1.0.138" serde_test = "1.0.176" @@ -240,7 +243,7 @@ static_assertions = "1.1" strum = "0.28" syn = { version = "2.0.117", features = ["full"] } sysinfo = "0.38.0" -tabled = { version = "0.20.0", default-features = false } +tabled = { version = "0.21.0", default-features = false } taffy = "0.10.0" take_mut = "0.2.2" tar = "0.4" @@ -250,7 +253,7 @@ tempfile = "3" termtree = { version = "1.0" } test-with = "0.16" thiserror = "2.0.3" -tokio = { version = "1.48" } +tokio = { version = "1.52" } tokio-stream = "0.1.17" tokio-util = "0.7.17" vortex-array-macros = { version = "0.1.0", path = "./vortex-array-macros" } @@ -261,8 +264,9 @@ tracing = { version = "0.1.41", default-features = false } tracing-perfetto = "0.1.5" tracing-subscriber = "0.3" url = "2.5.7" -uuid = { version = "1.21", features = ["js"] } +uuid = { version = "1.23", features = ["js"] } wasm-bindgen-futures = "0.4.54" +wkb = "0.9.2" xshell = "0.2.6" zigzag = "0.1.0" zip = "8.0.0" @@ -286,8 +290,10 @@ vortex-fastlanes = { version = "0.1.0", path = "./encodings/fastlanes", default- vortex-file = { version = "0.1.0", path = "./vortex-file", default-features = false } vortex-flatbuffers = { version = "0.1.0", path = "./vortex-flatbuffers", default-features = false } vortex-fsst = { version = "0.1.0", path = "./encodings/fsst", default-features = false } +vortex-geo = { version = "0.1.0", path = "./vortex-geo", default-features = false } vortex-io = { version = "0.1.0", path = "./vortex-io", default-features = false } vortex-ipc = { version = "0.1.0", path = "./vortex-ipc", default-features = false } +vortex-json = { version = "0.1.0", path = "./vortex-json", default-features = false } vortex-layout = { version = "0.1.0", path = "./vortex-layout", default-features = false } vortex-mask = { version = "0.1.0", path = "./vortex-mask", default-features = false } vortex-metrics = { version = "0.1.0", path = "./vortex-metrics", default-features = false } @@ -295,13 +301,13 @@ vortex-onpair = { version = "0.1.0", path = "./encodings/experimental/onpair", d vortex-parquet-variant = { version = "0.1.0", path = "./encodings/parquet-variant" } vortex-pco = { version = "0.1.0", path = "./encodings/pco", default-features = false } vortex-proto = { version = "0.1.0", path = "./vortex-proto", default-features = false } +vortex-row = { version = "0.1.0", path = "./vortex-row", default-features = false } vortex-runend = { version = "0.1.0", path = "./encodings/runend", default-features = false } vortex-scan = { version = "0.1.0", path = "./vortex-scan", default-features = false } vortex-sequence = { version = "0.1.0", path = "encodings/sequence", default-features = false } vortex-session = { version = "0.1.0", path = "./vortex-session", default-features = false } vortex-sparse = { version = "0.1.0", path = "./encodings/sparse", default-features = false } vortex-tensor = { version = "0.1.0", path = "./vortex-tensor", default-features = false } -vortex-turboquant = { version = "0.1.0", path = "./vortex-turboquant", default-features = false } vortex-utils = { version = "0.1.0", path = "./vortex-utils", default-features = false } vortex-zigzag = { version = "0.1.0", path = "./encodings/zigzag", default-features = false } vortex-zstd = { version = "0.1.0", path = "./encodings/zstd", default-features = false } @@ -387,7 +393,7 @@ debug = "full" inherits = "release" [profile.bench] -codegen-units = 1 +codegen-units = 16 debug = "full" lto = false @@ -409,6 +415,9 @@ incremental = false # This rule only applies to dependencies that are not members of the workspace. [profile.ci.package."*"] debug = false -debug-assertions = false -strip = "debuginfo" +debug-assertions = true incremental = false + +# This improved build times significantly for default common cases that we use locally +[profile.dev.package.vortex-fastlanes] +debug = false diff --git a/benchmarks-website/AGENTS.md b/benchmarks-website/AGENTS.md deleted file mode 100644 index 5d1ffde4006..00000000000 --- a/benchmarks-website/AGENTS.md +++ /dev/null @@ -1,116 +0,0 @@ - - -# AGENTS.md - `benchmarks-website/` - -Read [`README.md`](README.md) first for the architecture and the v2/v3 -side-by-side situation. Then this file. The root [`CLAUDE.md`](../CLAUDE.md) -covers Rust style, test layout, commit conventions. - -## Don't touch the v2 site - -Until the cutover PR lands, the top-level v2 files -(`server.js`, `src/`, `index.html`, `vite.config.js`, `package.json`, -`package-lock.json`, `public/`, the top-level `Dockerfile`, -`docker-compose.yml`) and the `publish-benchmarks-website.yml` workflow -are production. Don't edit them as part of unrelated work. - -The v3 deploy lives entirely under `server/`, `migrate/`, and `ops/`. -The operator runbook is [`ops/README.md`](ops/README.md). - -## v3 specifics - -- **Wire shapes are a coordinated change.** [`server/src/records.rs`](server/src/records.rs), - [`vortex-bench/src/v3.rs`](../vortex-bench/src/v3.rs), and (until cutover) - [`migrate/src/classifier.rs`](migrate/src/classifier.rs) must agree. - Bumping a shape means changing all three plus the snapshot fixtures in - one commit. `SCHEMA_VERSION` is the version literal coupled across two - named sites: [`server/src/schema.rs`](server/src/schema.rs) (source of - truth) and [`scripts/post-ingest.py`](../scripts/post-ingest.py) (the - CI ingest wrapper, which hardcodes it as a Python literal). Bump in - lockstep or every CI ingest run 400s. The server-side validation in - `records.rs` + `ingest.rs` and the echo in `/health` all consume the - constant through `crate::schema`. -- **Numeric `?n=` is clamped to 1000; `?n=all` is the uncapped escape - hatch.** HTML routes hydrate from the materialized latest-100 shard - artifact by default; `?n=all` is an explicit opt-in - (chart-init.js's full-history zoom-out hop uses it once, and curl - power users can request it). The numeric `?n=` path is bounded by - `MAX_NUMERIC_COMMIT_WINDOW` in [`server/src/api/window.rs`](server/src/api/window.rs) - as a DoS-protection floor against `curl ...?n=99999999`. If you need - full history, use `?n=all`. Do NOT raise the numeric cap or remove it - without thinking about the DoS surface. -- **`measurement_id` is server-internal.** Never put it on the wire. It is - a deterministic hash over `commit_sha` plus the dim tuple, computed in - [`server/src/db.rs`](server/src/db.rs) and reused by the migrator via - the same crate. -- **Don't write a server-side classifier for live ingest.** The emitter - produces v3-shape records directly; the migrator's classifier only - exists to translate v2 records once and goes away after cutover. -- **Don't reach for WASM.** SSR + a thin hydration script in - [`server/static/chart-init.js`](server/static/chart-init.js) is the - whole client. -- **v3 ingest is no longer best-effort in CI.** The `Ingest results to v3 - server` step in [`bench.yml`](../.github/workflows/bench.yml), - [`sql-benchmarks.yml`](../.github/workflows/sql-benchmarks.yml), and - [`v3-commit-metadata.yml`](../.github/workflows/v3-commit-metadata.yml) - no longer carries `continue-on-error: true`. A v3-server outage on a - develop push now fails the bench workflow and triggers the existing - `incident.io` alert. The gate is `vars.V3_INGEST_URL != ''` so forks - and unconfigured environments are unaffected. -- **Don't re-introduce a server-side commit cap on `?n=all`.** `?n=all` - is the uncapped escape hatch (chart-init.js fetches it once for the - zoom-out path); visual downsampling happens client-side via LTTB on - the visible commit range only. Numeric `?n=` is clamped per the bullet - above. Default fetches from chart-init.js use the materialized - latest-100 shard artifact, not `?n=all`. -- **Don't refetch on every scope change.** Once a chart's payload is in - memory, pan/zoom/slider/range-strip all rebuild in place via the - in-memory LTTB pass on the cached payload. The single exception is the - latest-100 to full-history zoom-out path: charts initially hydrate from - the materialized latest-100 group shard artifact (served from - `/api/artifacts/{generation}/groups/{slug}/shards/{i}`); when the user - zooms past that window for the first time, `chart-init.js` lazy-fetches - `?n=all` once and replaces the latest-100 payload in place. - -## Footguns we have already hit - -- **Reverse predecessor walk in the tooltip.** `payload.commits[]` is - sorted oldest-first by SQL - `commits[0]` is the oldest, `commits[N-1]` - is the newest. For per-row delta the predecessor of `commits[idx]` is - at `idx - 1`. We caught a regression where a "fix" flipped this to - `idx + 1`; the original walk-backward direction is right. -- **`pointer-events: auto` on the tooltip host.** The tooltip is - positioned at the cursor; making it pointer-interactive causes a - flicker loop. Keep it `pointer-events: none` and offset via - `transform: translate(12px, 12px)`. -- **`change` events on the slider.** Use `input` events with a small - throttle; `change` only fires on release and feels broken. - -## Local dev - -```bash -# Public-only run (read API + ingest only, admin routes 404): -INGEST_BEARER_TOKEN=dev cargo run -p vortex-bench-server - -# With admin endpoints mounted on a separate loopback listener: -INGEST_BEARER_TOKEN=dev ADMIN_BEARER_TOKEN=dev \ - cargo run -p vortex-bench-server - -cargo nextest run -p vortex-bench-server -p vortex-bench-migrate -INSTA_UPDATE=auto cargo nextest run -p vortex-bench-server # update snapshots -``` - -For the full env-var contract (admin bind, snapshot dir, extension dir, -logging spec, PaaS `PORT` fallback) see [`ops/config/vortex-bench.env.example`](ops/config/vortex-bench.env.example) -and the lib-level `//!` doc on [`server/src/main.rs`](server/src/main.rs). - -For the migrator end-to-end against the real S3 dump: - -```bash -cargo run -p vortex-bench-migrate -- run --output ./bench.duckdb -VORTEX_BENCH_DB=./bench.duckdb INGEST_BEARER_TOKEN=dev \ - cargo run -p vortex-bench-server -``` diff --git a/benchmarks-website/migrate/Cargo.toml b/benchmarks-website/migrate/Cargo.toml deleted file mode 100644 index ab448c90e7a..00000000000 --- a/benchmarks-website/migrate/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors - -[package] -name = "vortex-bench-migrate" -version = "0.1.0-alpha.0" -edition = "2024" -rust-version = "1.91.0" -license = "Apache-2.0" -description = "One-shot historical migrator from the v2 benchmarks S3 dataset to a v3 DuckDB file" -publish = false - -[[bin]] -name = "vortex-bench-migrate" -path = "src/main.rs" - -# Throwaway binary, not part of the vortex-* public API surface. -# Errors use anyhow, and the crate is intentionally outside the -# workspace. - -[dependencies] -anyhow = { workspace = true } -arrow-array = { workspace = true } -arrow-buffer = { workspace = true } -arrow-schema = { workspace = true } -clap = { workspace = true, features = ["derive"] } -# track vortex-duckdb's bundled engine version (build.rs) -duckdb = { version = "1.10502", features = ["bundled", "appender-arrow"] } -flate2 = "1.1" -reqwest = { workspace = true, features = ["json"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } -tracing = { workspace = true, features = ["std"] } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -vortex-bench-server = { path = "../server" } -vortex-utils = { workspace = true } - -[dev-dependencies] -rstest = { workspace = true } -tempfile = { workspace = true } diff --git a/benchmarks-website/migrate/build.rs b/benchmarks-website/migrate/build.rs deleted file mode 100644 index 37bb34d013a..00000000000 --- a/benchmarks-website/migrate/build.rs +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -fn main() { - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { - println!("cargo:rustc-link-lib=dylib=rstrtmgr"); - } -} diff --git a/benchmarks-website/migrate/src/classifier.rs b/benchmarks-website/migrate/src/classifier.rs deleted file mode 100644 index e473c2a6a02..00000000000 --- a/benchmarks-website/migrate/src/classifier.rs +++ /dev/null @@ -1,867 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Bug-for-bug port of v2's `getGroup`, `formatQuery`, and -//! `normalizeChartName` from `benchmarks-website/server.js`, plus the -//! mapping from v2 group + name pattern to a v3 fact-table bin. -//! -//! The v2 classifier was the source of truth for what historical -//! records mean. It groups records by name prefix into one of: -//! "Random Access", "Compression", "Compression Size", or one of the -//! SQL query suites (with optional fan-out by storage and scale -//! factor for TPC-H/TPC-DS). This module reproduces that logic and -//! then hops to a v3 fact-table bin, since v3 stores dim values as -//! columns instead of name fragments. -//! -//! Engine and format strings stored in v3 columns are pulled from the -//! raw, pre-rename v2 record name. v2's `ENGINE_RENAMES` was a v2 -//! read-time UI concern (e.g. `vortex-file-compressed` rendered as -//! `vortex` and `parquet-tokio-local-disk` rendered as `parquet-nvme`). -//! v3 stores canonical `Format::name()` strings to match what the v3 -//! live emitter writes, so historical and live records share series. - -use crate::v2::V2Record; -use crate::v2::dataset_scale_factor; - -/// Static port of v2's `QUERY_SUITES`. -pub const QUERY_SUITES: &[QuerySuite] = &[ - QuerySuite { - prefix: "clickbench", - display_name: "Clickbench", - query_prefix: "CLICKBENCH", - dataset_key: None, - fan_out: false, - skip: false, - }, - QuerySuite { - prefix: "statpopgen", - display_name: "Statistical and Population Genetics", - query_prefix: "STATPOPGEN", - dataset_key: None, - fan_out: false, - skip: false, - }, - QuerySuite { - prefix: "polarsignals", - display_name: "PolarSignals Profiling", - query_prefix: "POLARSIGNALS", - dataset_key: None, - fan_out: false, - skip: false, - }, - QuerySuite { - prefix: "gharchive", - display_name: "GhArchive", - query_prefix: "GHARCHIVE", - dataset_key: None, - fan_out: false, - skip: false, - }, - QuerySuite { - prefix: "tpch", - display_name: "TPC-H", - query_prefix: "TPC-H", - dataset_key: Some("tpch"), - fan_out: true, - skip: false, - }, - QuerySuite { - prefix: "tpcds", - display_name: "TPC-DS", - query_prefix: "TPC-DS", - dataset_key: Some("tpcds"), - fan_out: true, - skip: false, - }, - QuerySuite { - prefix: "fineweb", - display_name: "Fineweb", - query_prefix: "FINEWEB", - dataset_key: None, - fan_out: false, - skip: false, - }, -]; - -/// Static port of v2's `ENGINE_RENAMES`. Applied to the "series" half -/// of a benchmark name (the part after the first `/`) before splitting -/// on `:` into engine/format. Order doesn't matter — keys are unique. -const ENGINE_RENAMES: &[(&str, &str)] = &[ - ("datafusion:vortex-file-compressed", "datafusion:vortex"), - ("datafusion:parquet", "datafusion:parquet"), - ("datafusion:arrow", "datafusion:in-memory-arrow"), - ("datafusion:lance", "datafusion:lance"), - ("datafusion:vortex-compact", "datafusion:vortex-compact"), - ("duckdb:vortex-file-compressed", "duckdb:vortex"), - ("duckdb:parquet", "duckdb:parquet"), - ("duckdb:duckdb", "duckdb:duckdb"), - ("duckdb:vortex-compact", "duckdb:vortex-compact"), - ("vortex-tokio-local-disk", "vortex-nvme"), - ("vortex-compact-tokio-local-disk", "vortex-compact-nvme"), - ("lance-tokio-local-disk", "lance-nvme"), - ("parquet-tokio-local-disk", "parquet-nvme"), - ("lance", "lance"), -]; - -/// One entry of [`QUERY_SUITES`]. -#[derive(Debug, Clone, Copy)] -pub struct QuerySuite { - /// Lowercase suite prefix used to match v2 record names (e.g. `tpch`). - pub prefix: &'static str, - /// Human-readable suite name as v2 served it from `/api/metadata`. - pub display_name: &'static str, - /// Uppercase prefix v2's `formatQuery` produced (e.g. `TPC-H`). - pub query_prefix: &'static str, - /// Override for the dataset key v2 records use inside their `dataset` - /// object. Falls back to `prefix` when `None`. - pub dataset_key: Option<&'static str>, - /// True if the suite's group name fans out by `(storage, scale_factor)` - /// (e.g. `TPC-H (NVMe) (SF=1)`); false collapses to a single group. - pub fan_out: bool, - /// True if v2 deliberately ignored this suite (no live group is rendered). - pub skip: bool, -} - -/// Group a v2 record falls into. Mirrors `getGroup` in `server.js`, -/// including the fan-out group naming for TPC-H/TPC-DS. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum V2Group { - RandomAccess, - Compression, - CompressionSize, - Query { - suite_index: usize, - /// `Some` for fan-out suites only. - storage: Option, - /// `Some` for fan-out suites only. - scale_factor: Option, - }, -} - -impl V2Group { - /// Display name as v2 served it from `/api/metadata`. - pub fn display_name(&self) -> String { - match self { - V2Group::RandomAccess => "Random Access".into(), - V2Group::Compression => "Compression".into(), - V2Group::CompressionSize => "Compression Size".into(), - V2Group::Query { - suite_index, - storage, - scale_factor, - } => { - let suite = &QUERY_SUITES[*suite_index]; - if let (Some(storage), Some(sf)) = (storage, scale_factor) { - format!("{} ({}) (SF={})", suite.display_name, storage, sf) - } else { - suite.display_name.to_string() - } - } - } - } -} - -/// Apply v2's `ENGINE_RENAMES`. Reproduces the JS `rename`: -/// `RENAMES[s.toLowerCase()] || RENAMES[s] || s`. -pub fn rename_engine(s: &str) -> String { - let lower = s.to_lowercase(); - for (k, v) in ENGINE_RENAMES { - if *k == lower { - return (*v).to_string(); - } - } - for (k, v) in ENGINE_RENAMES { - if *k == s { - return (*v).to_string(); - } - } - s.to_string() -} - -/// Faithful port of v2's `formatQuery`: maps `clickbench_q07` → -/// `"CLICKBENCH Q7"`. Returns the original (uppercased, -/// `-` and `_` replaced with spaces) when no suite matches. -pub fn format_query(q: &str) -> String { - let lower = q.to_lowercase(); - for suite in QUERY_SUITES { - if suite.skip { - continue; - } - let prefix = suite.prefix; - if let Some(rest) = lower.strip_prefix(prefix) - && let Some(idx) = parse_query_index(rest) - { - return format!("{} Q{}", suite.query_prefix, idx); - } - } - let mut out = q.to_uppercase(); - out = out.replace(['_', '-'], " "); - out -} - -/// Parse the `_q07` / ` q7` / `q42` tail used by `format_query`. -/// Returns the integer query index if the tail matches the v2 regex -/// `^[_ ]?q(\d+)`. -fn parse_query_index(rest: &str) -> Option { - let after_sep = rest - .strip_prefix('_') - .or_else(|| rest.strip_prefix(' ')) - .unwrap_or(rest); - let after_q = after_sep - .strip_prefix('q') - .or_else(|| after_sep.strip_prefix('Q'))?; - let digits: String = after_q.chars().take_while(|c| c.is_ascii_digit()).collect(); - if digits.is_empty() { - return None; - } - digits.parse().ok() -} - -/// Faithful port of v2's `normalizeChartName`. -pub fn normalize_chart_name(group: &V2Group, chart_name: &str) -> String { - if matches!(group, V2Group::CompressionSize) && chart_name == "VORTEX FILE COMPRESSED SIZE" { - return "VORTEX SIZE".into(); - } - chart_name.to_string() -} - -/// Port of v2's `getGroup`. Returns `None` for skipped suites -/// (e.g. `fineweb`) or names that match nothing. -pub fn get_group(record: &V2Record) -> Option { - let lower = record.name.to_lowercase(); - - if lower.starts_with("random-access/") || lower.starts_with("random access/") { - return Some(V2Group::RandomAccess); - } - - if lower.starts_with("vortex size/") - || lower.starts_with("vortex-file-compressed size/") - || lower.starts_with("parquet size/") - || lower.starts_with("parquet-zstd size/") - || lower.starts_with("lance size/") - || lower.contains(":raw size/") - || lower.contains(":parquet-zstd size/") - || lower.contains(":lance size/") - { - return Some(V2Group::CompressionSize); - } - - if lower.starts_with("compress time/") - || lower.starts_with("decompress time/") - || lower.starts_with("parquet_rs-zstd compress") - || lower.starts_with("parquet_rs-zstd decompress") - || lower.starts_with("lance compress") - || lower.starts_with("lance decompress") - || lower.starts_with("vortex:lance ratio") - || lower.starts_with("vortex:parquet-zstd ratio") - // Typo'd v2 emitter wrote `parquet-zst` (no `d`) for some - // ratio records; match both spellings so they classify as - // derived ratios instead of falling through to Unknown. - || lower.starts_with("vortex:parquet-zst ratio") - || lower.starts_with("vortex:raw ratio") - { - return Some(V2Group::Compression); - } - - for (i, suite) in QUERY_SUITES.iter().enumerate() { - let prefix_q = format!("{}_q", suite.prefix); - let prefix_slash = format!("{}/", suite.prefix); - if !lower.starts_with(&prefix_q) && !lower.starts_with(&prefix_slash) { - continue; - } - if suite.skip { - return None; - } - if !suite.fan_out { - return Some(V2Group::Query { - suite_index: i, - storage: None, - scale_factor: None, - }); - } - let storage = match record.storage.as_deref().map(str::to_uppercase).as_deref() { - Some("S3") => "S3", - _ => "NVMe", - }; - let dataset_key = suite.dataset_key.unwrap_or(suite.prefix); - let raw_sf = record - .dataset - .as_ref() - .and_then(|d| dataset_scale_factor(d, dataset_key)); - let sf = raw_sf - .as_deref() - .and_then(|s| s.parse::().ok()) - .map(|f| f.round() as i64) - .unwrap_or(1); - return Some(V2Group::Query { - suite_index: i, - storage: Some(storage.into()), - scale_factor: Some(sf.to_string()), - }); - } - - None -} - -/// Group + chart + series breakdown for a v2 record, using the same -/// rules `server.js` applies in `refresh()`. Equivalent to v2's -/// `(group, chartName, seriesName)` triple after rename / skip rules. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct V2Classification { - /// Group the v2 server would place this record in. - pub group: V2Group, - /// Chart name v2 displayed for this record (uppercase, separators - /// normalized). - pub chart: String, - /// Series name after v2's `ENGINE_RENAMES` was applied. - pub series: String, -} - -/// Apply the same chart / series naming v2's `refresh()` does, plus -/// the throughput / `PARQUET-UNC` skip rules. -pub fn classify_v2(record: &V2Record) -> Option { - if record.name.contains(" throughput") { - return None; - } - let group = get_group(record)?; - let parts: Vec<&str> = record.name.split('/').collect(); - let (chart, series) = match (&group, parts.len()) { - (V2Group::RandomAccess, 4) => { - let chart = format!("{}/{}", parts[1], parts[2]) - .to_uppercase() - .replace(['_', '-'], " "); - let series = rename_engine(if parts[3].is_empty() { - "default" - } else { - parts[3] - }); - (chart, series) - } - (V2Group::RandomAccess, 2) => ( - "RANDOM ACCESS".to_string(), - rename_engine(if parts[1].is_empty() { - "default" - } else { - parts[1] - }), - ), - (V2Group::RandomAccess, _) => return None, - _ => { - let series_raw = if parts.len() >= 2 && !parts[1].is_empty() { - parts[1] - } else { - "default" - }; - let series = rename_engine(series_raw); - let chart = format_query(parts[0]); - (chart, series) - } - }; - let chart = normalize_chart_name(&group, &chart); - if chart.contains("PARQUET-UNC") { - return None; - } - Some(V2Classification { - group, - chart, - series, - }) -} - -/// Mapping target: which v3 fact table a v2 record lands in, plus the -/// dim values that table needs. -#[derive(Debug, Clone, PartialEq)] -pub enum V3Bin { - Query { - dataset: String, - dataset_variant: Option, - scale_factor: Option, - query_idx: i32, - storage: String, - engine: String, - format: String, - }, - CompressionTime { - dataset: String, - dataset_variant: Option, - format: String, - op: String, - }, - CompressionSize { - dataset: String, - dataset_variant: Option, - format: String, - }, - RandomAccess { - dataset: String, - format: String, - }, -} - -/// Top-level entry point. Combines `classify_v2` with the v3 fact-table -/// mapping. Returns `None` for records that: -/// -/// - Don't match any v2 group (uncategorized prefix). -/// - Are explicitly skipped by v2 (throughput, PARQUET-UNC, fineweb). -/// - Are computed-at-read-time ratios that v3 derives from -/// `compression_sizes` (`vortex:parquet-zstd ratio …`, -/// `vortex:lance ratio …`, `vortex:raw ratio …`, -/// `vortex:* size/…`). -pub fn classify(record: &V2Record) -> Option { - let cls = classify_v2(record)?; - match &cls.group { - V2Group::RandomAccess => bin_random_access(record), - V2Group::Compression => bin_compression_time(&cls, record), - V2Group::CompressionSize => bin_compression_size(&cls, record), - V2Group::Query { .. } => bin_query(&cls, record), - } -} - -/// Reason the classifier dropped a record. Intentional skips (v2 -/// patterns v3 deliberately doesn't store) are NOT errors; they don't -/// count against the uncategorized gate. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Skip { - /// `vortex:* ratio …` and `vortex:* size` — derived in v3 from - /// `compression_sizes` joined to itself. - DerivedRatio, - /// `throughput` records — v2 derived these from latencies. - Throughput, - /// A v2 query suite marked `skip: true` in QUERY_SUITES. - SkippedSuite, - /// random-access record with an unsupported part count. - UnsupportedShape, - /// Record had no `value` field. - NoValue, - /// Dim outside the v3 emitter's allowlist (e.g. `parquet-zstd`, - /// historical-only suites no longer in CI). - Deprecated, - /// v2 memory measurements (`*_memory/*` records). Carry top-level - /// `peak_physical_memory` / `peak_virtual_memory` / - /// `physical_memory_delta` / `virtual_memory_delta` fields that - /// `V2Record` doesn't deserialize. Not migrated for alpha; merging - /// into the corresponding QueryMeasurement row is future work. - HistoricalMemory, -} - -/// Engines the v3 emitter produces today. Mirrors -/// `vortex-bench/src/lib.rs::Engine`. Anything else is historical and gets -/// bucketed as `Skip::Deprecated`. -const V3_ENGINES: &[&str] = &["datafusion", "duckdb", "vortex", "arrow"]; - -/// Formats the v3 emitter produces today (`Format::name()` values from -/// `vortex-bench/src/lib.rs`). -const V3_FORMATS: &[&str] = &[ - "vortex-file-compressed", - "vortex-compact", - "parquet", - "lance", - "csv", - "arrow", - "duckdb", -]; - -/// Query suites the v3 CI runs today. Suites outside this list still -/// classify (so historical analyses stay coherent) but get bucketed -/// as `Skip::Deprecated` so they don't render as orphan charts in v3. -/// -/// `fineweb` is included because `.github/workflows/sql-benchmarks.yml` -/// still has `fineweb` and `fineweb-s3` matrix entries. `gharchive` -/// stays excluded — it's defined in `vortex-bench` but no current -/// workflow runs it. -const V3_QUERY_SUITES: &[&str] = &[ - "clickbench", - "tpch", - "tpcds", - "statpopgen", - "polarsignals", - "fineweb", -]; - -/// Returns true if every dim that v3 stores as a column is on the -/// emitter's current allowlist. Dim values outside the allowlist mean -/// historical-only formats / engines that the v3 UI has nothing to -/// render against. -fn is_v3_dim(bin: &V3Bin) -> bool { - match bin { - V3Bin::Query { engine, format, .. } => { - V3_ENGINES.contains(&engine.as_str()) && V3_FORMATS.contains(&format.as_str()) - } - V3Bin::CompressionTime { format, .. } - | V3Bin::CompressionSize { format, .. } - | V3Bin::RandomAccess { format, .. } => V3_FORMATS.contains(&format.as_str()), - } -} - -/// Outcome of running the classifier on a v2 record. Distinguishes -/// "we know we don't want this" (`Skip`) from "we don't recognize this" -/// (`Unknown`); the migrator's 5% gate fires only on the latter. -#[derive(Debug, Clone)] -pub enum Outcome { - Bin(V3Bin), - Skip(Skip), - Unknown, -} - -/// Like [`classify`], but reports *why* a record was dropped. Intended -/// for the migrator so the 5% uncategorized gate doesn't trip on -/// records v2 deliberately doesn't render (ratios, throughput, -/// skipped suites). -pub fn classify_outcome(record: &V2Record) -> Outcome { - if record.name.contains(" throughput") { - return Outcome::Skip(Skip::Throughput); - } - // v2 memory records: e.g. "clickbench_q07_memory/datafusion:parquet". - // Match the `_memory/` infix BEFORE the engine/format split, so they - // route to a known Skip variant instead of slipping through to - // Outcome::Unknown and tripping the 5% gate. - let lower = record.name.to_lowercase(); - if let Some((head, _)) = lower.split_once('/') - && head.ends_with("_memory") - { - return Outcome::Skip(Skip::HistoricalMemory); - } - let Some(group) = get_group(record) else { - return Outcome::Unknown; - }; - if let V2Group::Query { suite_index, .. } = &group - && QUERY_SUITES[*suite_index].skip - { - return Outcome::Skip(Skip::SkippedSuite); - } - let Some(cls) = classify_v2(record) else { - // get_group succeeded but classify_v2 didn't — shape mismatch. - return Outcome::Skip(Skip::UnsupportedShape); - }; - let derived = match &cls.group { - V2Group::Compression => { - let lc = cls.chart.to_lowercase(); - lc.contains("ratio") || lc.contains(':') - } - V2Group::CompressionSize => cls.chart.to_lowercase().contains(':'), - _ => false, - }; - if derived { - return Outcome::Skip(Skip::DerivedRatio); - } - let bin = match &cls.group { - V2Group::RandomAccess => match bin_random_access(record) { - Some(b) => Some(b), - // `bin_random_access` only returns None for malformed - // shapes (empty dataset/pattern segment, empty/`default` - // format). Route them to Skip so the `Outcome::Unknown` - // arm below — and the 5% uncategorized gate in - // `migrate::run` — don't trip on them. - None => return Outcome::Skip(Skip::UnsupportedShape), - }, - V2Group::Compression => bin_compression_time(&cls, record), - V2Group::CompressionSize => bin_compression_size(&cls, record), - V2Group::Query { .. } => bin_query(&cls, record), - }; - let Some(bin) = bin else { - return Outcome::Unknown; - }; - if !is_v3_dim(&bin) { - return Outcome::Skip(Skip::Deprecated); - } - if let V2Group::Query { suite_index, .. } = &group - && !V3_QUERY_SUITES.contains(&QUERY_SUITES[*suite_index].prefix) - { - return Outcome::Skip(Skip::Deprecated); - } - Outcome::Bin(bin) -} - -fn bin_random_access(record: &V2Record) -> Option { - // Pull dataset and format from the raw, pre-rename v2 name so v3 - // stores meaningful values. Two raw shapes are supported: - // - // - 4-part `random-access///-tokio-local-disk` - // - 2-part legacy `random-access/-tokio-local-disk` - // - // The 2-part shape is what `random-access-bench`'s `measurement_name` - // emits when called without an `AccessPattern`, and per its source - // comment that path is only taken for the legacy taxi run - // (`if dataset.name() == "taxi"` in `benchmarks/random-access-bench/ - // src/main.rs`). The live v3 emitter `random_access_record` writes - // `dataset="taxi"` for those same measurements, so the historical - // 2-part records are taxi too — assigning `dataset="taxi"` here - // recovers the time series instead of letting it disappear under - // v2's "RANDOM ACCESS" placeholder. Deriving from the raw name - // (rather than `cls.chart`) keeps this independent of v2's - // `normalizeChartName`. - // - // After stripping the `-tokio-local-disk` suffix, map the v2 - // random-access ext label (`vortex`, from `Format::ext()`) to the - // canonical name (`vortex-file-compressed`, from `Format::name()`). - // `parquet` and `lance` match between ext and name. The `vortex` - // ext is shared by both `OnDiskVortex` (name - // `vortex-file-compressed`) and `VortexCompact` (name - // `vortex-compact`), but v2's random-access bench only emitted - // `OnDiskVortex`, so mapping to `vortex-file-compressed` is - // correct for all historical data. - // - // Records whose `` segment ends in `-footer` (the bench's - // reopen-mode variant, e.g. `parquet-tokio-local-disk-footer`) - // intentionally do not strip clean to a v3-allowlisted format; the - // outer `is_v3_dim` filter then routes them to `Skip::Deprecated`. - // The live v3 emitter doesn't distinguish reopen vs cached either - // (`random_access_record` uses `format.name()` for both), so - // dropping `-footer` here keeps migration consistent with what - // v3 ingests live. - let parts: Vec<&str> = record.name.split('/').collect(); - let (dataset, raw_format) = match parts.as_slice() { - [_, ds, pat, format] => { - if ds.is_empty() || pat.is_empty() { - return None; - } - (format!("{ds}/{pat}").to_lowercase(), *format) - } - [_, format] => ("taxi".to_string(), *format), - _ => return None, - }; - if raw_format.is_empty() || raw_format == "default" { - return None; - } - let stripped = raw_format - .strip_suffix("-tokio-local-disk") - .unwrap_or(raw_format); - let format = match stripped { - "vortex" => "vortex-file-compressed".to_string(), - other => other.to_lowercase(), - }; - Some(V3Bin::RandomAccess { dataset, format }) -} - -fn bin_compression_time(cls: &V2Classification, _record: &V2Record) -> Option { - // v2 compression chart names look like (after format_query): - // "COMPRESS TIME" [vortex/encode] - // "DECOMPRESS TIME" [vortex/decode] - // "PARQUET RS ZSTD COMPRESS TIME" [parquet/encode] - // "PARQUET RS ZSTD DECOMPRESS TIME" [parquet/decode] - // "LANCE COMPRESS TIME" [lance/encode] - // "LANCE DECOMPRESS TIME" [lance/decode] - // "VORTEX:LANCE RATIO COMPRESS TIME" [drop] - // "VORTEX:PARQUET-ZSTD RATIO COMPRESS TIME" [drop] - // "VORTEX:RAW RATIO COMPRESS TIME" [drop] - let lc = cls.chart.to_lowercase(); - if lc.contains("ratio") || lc.contains(':') { - // Ratios are computed at read time from compression_sizes. - return None; - } - let (format, op) = if lc.starts_with("compress time") { - ("vortex-file-compressed", "encode") - } else if lc.starts_with("decompress time") { - ("vortex-file-compressed", "decode") - } else if lc.starts_with("parquet rs zstd compress time") { - ("parquet", "encode") - } else if lc.starts_with("parquet rs zstd decompress time") { - ("parquet", "decode") - } else if lc.starts_with("lance compress time") { - ("lance", "encode") - } else if lc.starts_with("lance decompress time") { - ("lance", "decode") - } else { - return None; - }; - let dataset = cls.series.to_lowercase(); - if dataset.is_empty() || dataset == "default" { - return None; - } - Some(V3Bin::CompressionTime { - dataset, - dataset_variant: None, - format: format.to_string(), - op: op.to_string(), - }) -} - -fn bin_compression_size(cls: &V2Classification, record: &V2Record) -> Option { - let lc = cls.chart.to_lowercase(); - // Ratios like "VORTEX:PARQUET ZSTD SIZE" / "VORTEX:LANCE SIZE" / - // "VORTEX:RAW SIZE" are derived from compression_sizes at read - // time, not stored. - if lc.contains(':') { - return None; - } - // `parquet-zstd size` shares a leading "parquet" with `parquet size`, - // so check the more specific prefix first. `format_query` upper-cases - // and replaces `-`/`_` with spaces, so the chart we match against is - // `"PARQUET ZSTD SIZE"` (no hyphen) — same convention as the existing - // `"parquet rs zstd compress time"` branches above. - let format = if lc.starts_with("vortex size") { - "vortex-file-compressed" - } else if lc.starts_with("parquet zstd size") { - "parquet-zstd" - } else if lc.starts_with("parquet size") { - "parquet" - } else if lc.starts_with("lance size") { - "lance" - } else { - return None; - }; - let dataset = cls.series.to_lowercase(); - if dataset.is_empty() || dataset == "default" { - return None; - } - // Mirror the file-sizes ingest path's dataset_variant derivation - // (see `migrate::migrate_file_sizes`): pull the SF out of the v2 - // record's `dataset` object when present and run it through - // `canonical_scale_factor` so `"1"`, `"1.0"`, `"10"` and `"10.0"` - // collapse to one canonical form. Without this both code paths - // produce the same `mid` only by accident, so SF=10 file-sizes - // rows wouldn't merge with the matching data.json.gz - // "vortex size/tpch" rows when one side wrote `"10"` and the - // other wrote `"10.0"`. - let dataset_variant = crate::v2::canonical_scale_factor( - record - .dataset - .as_ref() - .and_then(|d| crate::v2::dataset_scale_factor(d, dataset.as_str())) - .as_deref(), - ); - Some(V3Bin::CompressionSize { - dataset, - dataset_variant, - format: format.to_string(), - }) -} - -fn bin_query(cls: &V2Classification, record: &V2Record) -> Option { - let V2Group::Query { - suite_index, - storage, - scale_factor, - } = &cls.group - else { - return None; - }; - let suite = &QUERY_SUITES[*suite_index]; - - // Pull the query index from the *raw* name's first part instead of - // the formatted chart, so we don't have to round-trip "Q07". - let raw_first = record.name.split('/').next().unwrap_or(""); - let query_idx = parse_query_index_from_first(raw_first)?; - - // Pull engine:format from the raw, pre-rename second segment so v3 - // stores canonical `Format::name()` strings (e.g. - // `vortex-file-compressed`) that match what the v3 live emitter - // writes. `cls.series` has been through v2's `ENGINE_RENAMES` for - // UI display and is not appropriate for v3 columns. - // - // Older v2 records emitted display-case engines (e.g. `DataFusion`, - // `DuckDB`); newer ones emit lowercase. Lowercase here so dedup - // collapses both spellings into a single canonical row. - let raw_series = record.name.split('/').nth(1)?; - let (engine, format) = split_engine_format(raw_series)?; - let engine = engine.to_lowercase(); - let format = format.to_lowercase(); - - let storage_v3 = match storage.as_deref() { - Some("S3") => "s3".to_string(), - Some("NVMe") => "nvme".to_string(), - _ => "nvme".to_string(), - }; - - // ClickBench's "flavor" lives in `dataset_variant`, but v2 record names - // never encoded it — leave it `None` so historical and live rows merge - // (the live emitter does the same; see `vortex-bench/src/v3.rs`'s - // `benchmark_dataset_dims` for the matching shape). - Some(V3Bin::Query { - dataset: suite.prefix.to_string(), - dataset_variant: None, - scale_factor: scale_factor.clone(), - query_idx, - storage: storage_v3, - engine, - format, - }) -} - -/// Pull the integer query index out of the leading name part, which is -/// always `_q` or ` q` for SQL query records. -fn parse_query_index_from_first(first: &str) -> Option { - let lower = first.to_lowercase(); - for suite in QUERY_SUITES { - if let Some(rest) = lower.strip_prefix(suite.prefix) - && let Some(idx) = parse_query_index(rest) - { - return Some(idx as i32); - } - } - None -} - -/// Split a renamed series like `datafusion:parquet` into -/// `(engine, format)`. Returns `None` for series with no `:` since -/// v3 requires both columns. -fn split_engine_format(series: &str) -> Option<(String, String)> { - let mut split = series.splitn(2, ':'); - let engine = split.next()?.trim().to_string(); - let format = split.next()?.trim().to_string(); - if engine.is_empty() || format.is_empty() { - return None; - } - Some((engine, format)) -} - -#[cfg(test)] -mod tests { - use anyhow::Context as _; - - use super::*; - - fn record(name: &str) -> V2Record { - V2Record { - name: name.to_string(), - commit_id: Some("deadbeef".into()), - unit: None, - value: None, - storage: None, - dataset: None, - all_runtimes: None, - env_triple: None, - } - } - - #[test] - fn format_query_round_trips() { - assert_eq!(format_query("clickbench_q07"), "CLICKBENCH Q7"); - assert_eq!(format_query("tpch_q01"), "TPC-H Q1"); - assert_eq!(format_query("tpcds_q42"), "TPC-DS Q42"); - assert_eq!(format_query("statpopgen_q3"), "STATPOPGEN Q3"); - assert_eq!(format_query("foo bar"), "FOO BAR"); - } - - #[test] - fn rename_engine_canonicalizes_disk_names() { - assert_eq!(rename_engine("vortex-tokio-local-disk"), "vortex-nvme"); - assert_eq!( - rename_engine("datafusion:vortex-file-compressed"), - "datafusion:vortex" - ); - assert_eq!(rename_engine("unknown-engine"), "unknown-engine"); - } - - #[test] - fn parse_query_index_handles_separators() { - assert_eq!(parse_query_index("_q07"), Some(7)); - assert_eq!(parse_query_index(" q7"), Some(7)); - assert_eq!(parse_query_index("q42"), Some(42)); - assert_eq!(parse_query_index("xq7"), None); - } - - #[test] - fn random_access_bins_dataset_pattern() -> anyhow::Result<()> { - let bin = classify(&record("random-access/taxi/take/parquet")) - .context("classify returned None for a known-good 4-part random-access name")?; - assert_eq!( - bin, - V3Bin::RandomAccess { - dataset: "taxi/take".into(), - format: "parquet".into(), - } - ); - Ok(()) - } -} diff --git a/benchmarks-website/migrate/src/commits.rs b/benchmarks-website/migrate/src/commits.rs deleted file mode 100644 index a9c5f056cd7..00000000000 --- a/benchmarks-website/migrate/src/commits.rs +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Commit upserts. Adapts a [`crate::v2::V2Commit`] into the v3 -//! `commits` row shape (a [`vortex_bench_server::records::CommitInfo`]). - -use anyhow::Context as _; -use anyhow::Result; -use duckdb::Transaction; -use duckdb::params; - -use crate::v2::V2Commit; - -/// Insert a v3 `commits` row for one v2 commit. `tree_sha` and `url` -/// remain required and use a warning-bearing empty-string fallback; -/// the human-input fields (message, author/committer name and email) -/// are nullable in the v3 schema, so empty / missing values map to -/// SQL `NULL` instead of an empty string the UI would render as a -/// blank cell. -pub fn upsert_commit(tx: &Transaction<'_>, commit: &V2Commit) -> Result { - let mut warnings = Vec::new(); - let timestamp = require_field(&commit.timestamp, "timestamp", &commit.id, &mut warnings); - let message = optional_field(&commit.message); - let author_name = optional_field(&commit.author.as_ref().and_then(|p| p.name.clone())); - let author_email = optional_field(&commit.author.as_ref().and_then(|p| p.email.clone())); - let committer_name = optional_field(&commit.committer.as_ref().and_then(|p| p.name.clone())); - let committer_email = optional_field(&commit.committer.as_ref().and_then(|p| p.email.clone())); - let tree_sha = require_field(&commit.tree_id, "tree_id", &commit.id, &mut warnings); - let url = require_field(&commit.url, "url", &commit.id, &mut warnings); - - tx.execute( - r#" - INSERT INTO commits ( - commit_sha, timestamp, message, author_name, author_email, - committer_name, committer_email, tree_sha, url - ) VALUES (?, CAST(? AS TIMESTAMPTZ), ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (commit_sha) DO UPDATE SET - timestamp = excluded.timestamp, - message = excluded.message, - author_name = excluded.author_name, - author_email = excluded.author_email, - committer_name = excluded.committer_name, - committer_email = excluded.committer_email, - tree_sha = excluded.tree_sha, - url = excluded.url - "#, - params![ - commit.id, - timestamp, - message, - author_name, - author_email, - committer_name, - committer_email, - tree_sha, - url, - ], - ) - .with_context(|| format!("upserting commit {}", commit.id))?; - Ok(UpsertOutcome { warnings }) -} - -fn require_field( - field: &Option, - name: &str, - sha: &str, - warnings: &mut Vec, -) -> String { - match field { - Some(s) => s.clone(), - None => { - warnings.push(format!("commit {sha} missing {name}")); - String::new() - } - } -} - -/// Coerce a v2-supplied `Option` into a SQL-bindable -/// `Option`, treating an empty / whitespace-only value as -/// missing. v2 sometimes wrote `""` for blank author / committer / -/// message fields; storing those as actual `NULL` lets the UI -/// distinguish "missing metadata" from "deliberately blank". -fn optional_field(field: &Option) -> Option { - field - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - -/// Per-call warning bag returned to the caller for logging. -#[derive(Debug, Default)] -pub struct UpsertOutcome { - /// Human-readable warnings — typically one per missing required field on - /// the v2 commit (timestamp, tree_id, url). - pub warnings: Vec, -} diff --git a/benchmarks-website/migrate/src/lib.rs b/benchmarks-website/migrate/src/lib.rs deleted file mode 100644 index b5aa72bc97d..00000000000 --- a/benchmarks-website/migrate/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! One-shot historical migrator from v2's S3-hosted benchmark dataset -//! to a v3 DuckDB file. -//! -//! The v2 dataset is JSONL of bare benchmark records keyed by name string. -//! v3 uses five typed fact tables with explicit dim columns. This crate -//! ports v2's `getGroup` classifier (in `benchmarks-website/server.js`) -//! bug-for-bug so that historical rows survive the migration with the -//! same group / chart / series structure as the live v2 server. -//! -//! The migrator is throwaway: once v3 cuts over, both the binary and -//! the classifier go away. - -/// Routing v2 records into v3 fact tables, ported from v2's `getGroup`. -pub mod classifier; -/// V2 commit -> v3 `commits` row upserts. -pub mod commits; -/// End-to-end migration of v2 dumps into a v3 DuckDB. -pub mod migrate; -/// Streaming readers for the v2 S3 bucket and local dumps. -pub mod source; -/// Wire shapes of the v2 benchmark dataset. -pub mod v2; -/// Structural diff between a migrated v3 DuckDB and v2's `/api/metadata`. -pub mod verify; diff --git a/benchmarks-website/migrate/src/main.rs b/benchmarks-website/migrate/src/main.rs deleted file mode 100644 index abd3a4a8a83..00000000000 --- a/benchmarks-website/migrate/src/main.rs +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! `vortex-bench-migrate` CLI: a one-shot historical migrator from -//! v2's S3 dataset into a v3 DuckDB file, plus a structural diff -//! against the live v2 `/api/metadata` endpoint for spotting -//! classifier regressions. - -use std::path::PathBuf; -use std::process::ExitCode; - -use anyhow::Context as _; -use anyhow::Result; -use clap::Parser; -use clap::Subcommand; -use clap::ValueEnum; -use tracing_subscriber::EnvFilter; -use vortex_bench_migrate::migrate; -use vortex_bench_migrate::source::Source; -use vortex_bench_migrate::verify; - -/// One-shot historical migrator from v2's S3 dataset to v3 DuckDB. -#[derive(Debug, Parser)] -#[command(name = "vortex-bench-migrate", version, about)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Read v2's data.json.gz / commits.json / file-sizes-*.json.gz - /// and write a fully populated v3 DuckDB at `--output`. - Run { - /// Path to write the v3 DuckDB to. Created if absent. - #[arg(long)] - output: PathBuf, - /// Where to fetch v2 dumps from. - #[arg(long, value_enum, default_value_t = SourceKind::PublicS3)] - source: SourceKind, - /// For `--source=local`, the directory containing - /// `data.json.gz`, `commits.json`, and `file-sizes-*.json.gz`. - #[arg(long, required_if_eq("source", "local"))] - source_dir: Option, - /// Continue past per-`file-sizes-*.json.gz` failures rather than - /// failing the migration. By default a single failed - /// `file-sizes-*` source is an error, because a "successful" - /// migrated DB with missing compression-size history is a worse - /// outcome than a loud failure that the operator can retry. Pass - /// this flag when you genuinely want partial coverage (e.g. one - /// known-bad source file you want to skip). - #[arg(long, default_value_t = false)] - allow_missing_file_sizes: bool, - }, - /// Diff a migrated DuckDB against the live v2 `/api/metadata` - /// endpoint. Exits 0 if every v2 group is present in v3, 1 - /// otherwise so this can gate a CI step. - Verify { - /// HTTPS root of a running v2 server (e.g. `https://bench.vortex.dev`). - #[arg(long)] - against: String, - /// Path to the migrated v3 DuckDB. - #[arg(long)] - duckdb: PathBuf, - }, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum SourceKind { - PublicS3, - Local, -} - -fn main() -> ExitCode { - if let Err(err) = run() { - eprintln!("error: {err:#}"); - return ExitCode::from(2); - } - ExitCode::SUCCESS -} - -fn run() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_env("VORTEX_BENCH_LOG").unwrap_or_else(|_| EnvFilter::new("info")), - ) - .init(); - - let cli = Cli::parse(); - match cli.command { - Command::Run { - output, - source, - source_dir, - allow_missing_file_sizes, - } => { - let source = match source { - SourceKind::PublicS3 => Source::PublicS3, - SourceKind::Local => { - Source::Local(source_dir.context("--source=local requires --source-dir")?) - } - }; - let summary = migrate::run(&source, &output)?; - print!("{summary}"); - if summary.uncategorized_fraction() > 0.05 { - anyhow::bail!( - "uncategorized records ({:.2}%) exceed the 5% gate; \ - stop and report unmatched prefixes (see summary above) \ - before proceeding", - 100.0 * summary.uncategorized_fraction() - ); - } - if summary.file_sizes_failed > 0 && !allow_missing_file_sizes { - anyhow::bail!( - "{} file-sizes-*.json.gz source file(s) failed (see warnings above); \ - re-run with --allow-missing-file-sizes if partial coverage is intended", - summary.file_sizes_failed - ); - } - Ok(()) - } - Command::Verify { against, duckdb } => { - let report = verify::run(&against, &duckdb)?; - print!("{report}"); - if !report.v2_groups_covered() { - std::process::exit(1); - } - Ok(()) - } - } -} diff --git a/benchmarks-website/migrate/src/migrate/accum.rs b/benchmarks-website/migrate/src/migrate/accum.rs deleted file mode 100644 index 69f16fe4b93..00000000000 --- a/benchmarks-website/migrate/src/migrate/accum.rs +++ /dev/null @@ -1,357 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Per-fact-table row accumulators + their `RecordBatch` builders. -//! -//! Each `*Accum` collects classified records during the streaming JSONL -//! pass and then materialises one Arrow `RecordBatch` per fact table at -//! flush time. Three of the four use parallel column vectors with a -//! `seen` map keyed by `measurement_id`; `CompressionSizeAccum` is a -//! `HashMap` because it has two collision semantics -//! (replace from `data.json.gz`, sum from `file-sizes-*.json.gz`). - -use std::sync::Arc; - -use anyhow::Result; -use arrow_array::ArrayRef; -use arrow_array::Int32Array; -use arrow_array::Int64Array; -use arrow_array::ListArray; -use arrow_array::RecordBatch; -use arrow_array::StringArray; -use arrow_buffer::OffsetBuffer; -use arrow_schema::DataType; -use arrow_schema::Field; -use arrow_schema::Schema; -use vortex_bench_server::records::CompressionSize; -use vortex_bench_server::records::CompressionTime; -use vortex_bench_server::records::QueryMeasurement; -use vortex_bench_server::records::RandomAccessTime; -use vortex_utils::aliases::hash_map::HashMap; - -use super::MigrationSummary; - -/// `query_measurements` accumulator. Parallel column vectors plus a -/// `measurement_id`-keyed seen map; first-write wins on collision. -#[derive(Default)] -pub(super) struct QueryAccum { - pub(super) measurement_id: Vec, - pub(super) commit_sha: Vec, - pub(super) dataset: Vec, - pub(super) dataset_variant: Vec>, - pub(super) scale_factor: Vec>, - pub(super) query_idx: Vec, - pub(super) storage: Vec, - pub(super) engine: Vec, - pub(super) format: Vec, - pub(super) value_ns: Vec, - pub(super) all_runtimes_ns: Vec>, - pub(super) peak_physical: Vec>, - pub(super) peak_virtual: Vec>, - pub(super) physical_delta: Vec>, - pub(super) virtual_delta: Vec>, - pub(super) env_triple: Vec>, - /// `mid` -> index in the parallel column vecs. Lets us look up the - /// kept row's `value_ns` on collision so we can flag conflicts. - pub(super) seen: HashMap, -} - -impl QueryAccum { - pub(super) fn push(&mut self, mid: i64, r: QueryMeasurement, summary: &mut MigrationSummary) { - if let Some(&idx) = self.seen.get(&mid) { - summary.deduped += 1; - if self.value_ns[idx] != r.value_ns { - summary.deduped_with_conflict += 1; - } - return; - } - let idx = self.measurement_id.len(); - self.seen.insert(mid, idx); - self.measurement_id.push(mid); - self.commit_sha.push(r.commit_sha); - self.dataset.push(r.dataset); - self.dataset_variant.push(r.dataset_variant); - self.scale_factor.push(r.scale_factor); - self.query_idx.push(r.query_idx); - self.storage.push(r.storage); - self.engine.push(r.engine); - self.format.push(r.format); - self.value_ns.push(r.value_ns); - self.all_runtimes_ns.push(r.all_runtimes_ns); - self.peak_physical.push(r.peak_physical); - self.peak_virtual.push(r.peak_virtual); - self.physical_delta.push(r.physical_delta); - self.virtual_delta.push(r.virtual_delta); - self.env_triple.push(r.env_triple); - } -} - -/// `compression_times` accumulator. Same shape as [`QueryAccum`] minus the -/// query-only columns. -#[derive(Default)] -pub(super) struct CompressionTimeAccum { - pub(super) measurement_id: Vec, - pub(super) commit_sha: Vec, - pub(super) dataset: Vec, - pub(super) dataset_variant: Vec>, - pub(super) format: Vec, - pub(super) op: Vec, - pub(super) value_ns: Vec, - pub(super) all_runtimes_ns: Vec>, - pub(super) env_triple: Vec>, - pub(super) seen: HashMap, -} - -impl CompressionTimeAccum { - pub(super) fn push(&mut self, mid: i64, r: CompressionTime, summary: &mut MigrationSummary) { - if let Some(&idx) = self.seen.get(&mid) { - summary.deduped += 1; - if self.value_ns[idx] != r.value_ns { - summary.deduped_with_conflict += 1; - } - return; - } - let idx = self.measurement_id.len(); - self.seen.insert(mid, idx); - self.measurement_id.push(mid); - self.commit_sha.push(r.commit_sha); - self.dataset.push(r.dataset); - self.dataset_variant.push(r.dataset_variant); - self.format.push(r.format); - self.op.push(r.op); - self.value_ns.push(r.value_ns); - self.all_runtimes_ns.push(r.all_runtimes_ns); - self.env_triple.push(r.env_triple); - } -} - -/// `random_access_times` accumulator. Smallest of the three parallel-vec -/// accumulators. -#[derive(Default)] -pub(super) struct RandomAccessAccum { - pub(super) measurement_id: Vec, - pub(super) commit_sha: Vec, - pub(super) dataset: Vec, - pub(super) format: Vec, - pub(super) value_ns: Vec, - pub(super) all_runtimes_ns: Vec>, - pub(super) env_triple: Vec>, - pub(super) seen: HashMap, -} - -impl RandomAccessAccum { - pub(super) fn push(&mut self, mid: i64, r: RandomAccessTime, summary: &mut MigrationSummary) { - if let Some(&idx) = self.seen.get(&mid) { - summary.deduped += 1; - if self.value_ns[idx] != r.value_ns { - summary.deduped_with_conflict += 1; - } - return; - } - let idx = self.measurement_id.len(); - self.seen.insert(mid, idx); - self.measurement_id.push(mid); - self.commit_sha.push(r.commit_sha); - self.dataset.push(r.dataset); - self.format.push(r.format); - self.value_ns.push(r.value_ns); - self.all_runtimes_ns.push(r.all_runtimes_ns); - self.env_triple.push(r.env_triple); - } -} - -/// `compression_sizes` is fed by both `data.json.gz` (replace-on-collision) -/// and `file-sizes-*.json.gz` (sum-on-collision). Stored as a map; converted -/// to a `RecordBatch` at flush time. -#[derive(Default)] -pub(super) struct CompressionSizeAccum { - pub(super) rows: HashMap, -} - -impl CompressionSizeAccum { - /// data.json.gz path: latest write wins, mirroring the prior - /// `ON CONFLICT DO UPDATE SET value_bytes = excluded.value_bytes`. - /// Bumps `deduped_with_conflict` when an existing row's - /// `value_bytes` differs from the incoming row's, so silent - /// value-corruption is observable. - pub(super) fn push_replace( - &mut self, - mid: i64, - r: CompressionSize, - summary: &mut MigrationSummary, - ) { - if let Some(existing) = self.rows.get(&mid) - && existing.value_bytes != r.value_bytes - { - summary.deduped_with_conflict += 1; - } - self.rows.insert(mid, r); - } - - /// file-sizes-*.json.gz path: per-file rows aggregate into one - /// `(commit, dataset, dataset_variant, format)` row by summing, - /// mirroring the prior `value_bytes = compression_sizes.value_bytes - /// + excluded.value_bytes`. - pub(super) fn push_sum(&mut self, mid: i64, r: CompressionSize) { - let add = r.value_bytes; - self.rows - .entry(mid) - .and_modify(|x| x.value_bytes += add) - .or_insert(r); - } -} - -pub(super) fn build_query_batch(a: QueryAccum) -> Result { - let schema = Arc::new(Schema::new(vec![ - Field::new("measurement_id", DataType::Int64, false), - Field::new("commit_sha", DataType::Utf8, false), - Field::new("dataset", DataType::Utf8, false), - Field::new("dataset_variant", DataType::Utf8, true), - Field::new("scale_factor", DataType::Utf8, true), - Field::new("query_idx", DataType::Int32, false), - Field::new("storage", DataType::Utf8, false), - Field::new("engine", DataType::Utf8, false), - Field::new("format", DataType::Utf8, false), - Field::new("value_ns", DataType::Int64, false), - Field::new( - "all_runtimes_ns", - DataType::List(Arc::new(Field::new("item", DataType::Int64, false))), - false, - ), - Field::new("peak_physical", DataType::Int64, true), - Field::new("peak_virtual", DataType::Int64, true), - Field::new("physical_delta", DataType::Int64, true), - Field::new("virtual_delta", DataType::Int64, true), - Field::new("env_triple", DataType::Utf8, true), - ])); - let cols: Vec = vec![ - Arc::new(Int64Array::from(a.measurement_id)), - Arc::new(StringArray::from(a.commit_sha)), - Arc::new(StringArray::from(a.dataset)), - Arc::new(StringArray::from(a.dataset_variant)), - Arc::new(StringArray::from(a.scale_factor)), - Arc::new(Int32Array::from(a.query_idx)), - Arc::new(StringArray::from(a.storage)), - Arc::new(StringArray::from(a.engine)), - Arc::new(StringArray::from(a.format)), - Arc::new(Int64Array::from(a.value_ns)), - Arc::new(build_list_int64(a.all_runtimes_ns)), - Arc::new(Int64Array::from(a.peak_physical)), - Arc::new(Int64Array::from(a.peak_virtual)), - Arc::new(Int64Array::from(a.physical_delta)), - Arc::new(Int64Array::from(a.virtual_delta)), - Arc::new(StringArray::from(a.env_triple)), - ]; - Ok(RecordBatch::try_new(schema, cols)?) -} - -pub(super) fn build_compression_time_batch(a: CompressionTimeAccum) -> Result { - let schema = Arc::new(Schema::new(vec![ - Field::new("measurement_id", DataType::Int64, false), - Field::new("commit_sha", DataType::Utf8, false), - Field::new("dataset", DataType::Utf8, false), - Field::new("dataset_variant", DataType::Utf8, true), - Field::new("format", DataType::Utf8, false), - Field::new("op", DataType::Utf8, false), - Field::new("value_ns", DataType::Int64, false), - Field::new( - "all_runtimes_ns", - DataType::List(Arc::new(Field::new("item", DataType::Int64, false))), - false, - ), - Field::new("env_triple", DataType::Utf8, true), - ])); - let cols: Vec = vec![ - Arc::new(Int64Array::from(a.measurement_id)), - Arc::new(StringArray::from(a.commit_sha)), - Arc::new(StringArray::from(a.dataset)), - Arc::new(StringArray::from(a.dataset_variant)), - Arc::new(StringArray::from(a.format)), - Arc::new(StringArray::from(a.op)), - Arc::new(Int64Array::from(a.value_ns)), - Arc::new(build_list_int64(a.all_runtimes_ns)), - Arc::new(StringArray::from(a.env_triple)), - ]; - Ok(RecordBatch::try_new(schema, cols)?) -} - -pub(super) fn build_random_access_batch(a: RandomAccessAccum) -> Result { - let schema = Arc::new(Schema::new(vec![ - Field::new("measurement_id", DataType::Int64, false), - Field::new("commit_sha", DataType::Utf8, false), - Field::new("dataset", DataType::Utf8, false), - Field::new("format", DataType::Utf8, false), - Field::new("value_ns", DataType::Int64, false), - Field::new( - "all_runtimes_ns", - DataType::List(Arc::new(Field::new("item", DataType::Int64, false))), - false, - ), - Field::new("env_triple", DataType::Utf8, true), - ])); - let cols: Vec = vec![ - Arc::new(Int64Array::from(a.measurement_id)), - Arc::new(StringArray::from(a.commit_sha)), - Arc::new(StringArray::from(a.dataset)), - Arc::new(StringArray::from(a.format)), - Arc::new(Int64Array::from(a.value_ns)), - Arc::new(build_list_int64(a.all_runtimes_ns)), - Arc::new(StringArray::from(a.env_triple)), - ]; - Ok(RecordBatch::try_new(schema, cols)?) -} - -pub(super) fn build_compression_size_batch(a: CompressionSizeAccum) -> Result { - let n = a.rows.len(); - let mut measurement_id = Vec::with_capacity(n); - let mut commit_sha = Vec::with_capacity(n); - let mut dataset = Vec::with_capacity(n); - let mut dataset_variant = Vec::with_capacity(n); - let mut format = Vec::with_capacity(n); - let mut value_bytes = Vec::with_capacity(n); - for (mid, cs) in a.rows { - measurement_id.push(mid); - commit_sha.push(cs.commit_sha); - dataset.push(cs.dataset); - dataset_variant.push(cs.dataset_variant); - format.push(cs.format); - value_bytes.push(cs.value_bytes); - } - let schema = Arc::new(Schema::new(vec![ - Field::new("measurement_id", DataType::Int64, false), - Field::new("commit_sha", DataType::Utf8, false), - Field::new("dataset", DataType::Utf8, false), - Field::new("dataset_variant", DataType::Utf8, true), - Field::new("format", DataType::Utf8, false), - Field::new("value_bytes", DataType::Int64, false), - ])); - let cols: Vec = vec![ - Arc::new(Int64Array::from(measurement_id)), - Arc::new(StringArray::from(commit_sha)), - Arc::new(StringArray::from(dataset)), - Arc::new(StringArray::from(dataset_variant)), - Arc::new(StringArray::from(format)), - Arc::new(Int64Array::from(value_bytes)), - ]; - Ok(RecordBatch::try_new(schema, cols)?) -} - -/// Build a non-nullable `List` Arrow array from one inner Vec -/// per row. The outer list is non-null; inner i64 values are non-null. -fn build_list_int64(values: Vec>) -> ListArray { - let mut offsets: Vec = Vec::with_capacity(values.len() + 1); - offsets.push(0); - let mut flat: Vec = Vec::new(); - for inner in values { - flat.extend_from_slice(&inner); - offsets.push(flat.len() as i32); - } - let values_arr = Int64Array::from(flat); - let field = Arc::new(Field::new("item", DataType::Int64, false)); - ListArray::new( - field, - OffsetBuffer::new(offsets.into()), - Arc::new(values_arr), - None, - ) -} diff --git a/benchmarks-website/migrate/src/migrate/mod.rs b/benchmarks-website/migrate/src/migrate/mod.rs deleted file mode 100644 index 6c58fe459ed..00000000000 --- a/benchmarks-website/migrate/src/migrate/mod.rs +++ /dev/null @@ -1,652 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! End-to-end migration of one v2 dataset into a v3 DuckDB file. -//! -//! Streams `data.json.gz` line-by-line, runs each record through the -//! [`crate::classifier`], and writes one row per record into the appropriate -//! v3 fact table. Every row's `measurement_id` is computed via the server's -//! `measurement_id_*` functions so the result is byte-compatible with what -//! fresh `/api/ingest` would have produced. -//! -//! Bulk-load shape: rows are accumulated in memory as parallel column -//! vectors, deduplicated by `measurement_id`, then flushed to DuckDB -//! via `Appender::append_record_batch` as one Arrow `RecordBatch` per -//! fact table. - -mod accum; - -use std::collections::BTreeMap; -use std::io::BufRead; -use std::path::Path; -use std::time::Duration; -use std::time::Instant; - -use anyhow::Context as _; -use anyhow::Result; -use arrow_array::RecordBatch; -use duckdb::Connection; -use tracing::info; -use tracing::warn; -use vortex_bench_server::db::measurement_id_compression_size; -use vortex_bench_server::db::measurement_id_compression_time; -use vortex_bench_server::db::measurement_id_query; -use vortex_bench_server::db::measurement_id_random_access; -use vortex_bench_server::family; -use vortex_bench_server::records::CompressionSize; -use vortex_bench_server::records::CompressionTime; -use vortex_bench_server::records::QueryMeasurement; -use vortex_bench_server::records::RandomAccessTime; -use vortex_bench_server::schema::COMMITS_DDL; - -use self::accum::CompressionSizeAccum; -use self::accum::CompressionTimeAccum; -use self::accum::QueryAccum; -use self::accum::RandomAccessAccum; -use self::accum::build_compression_size_batch; -use self::accum::build_compression_time_batch; -use self::accum::build_query_batch; -use self::accum::build_random_access_batch; -use crate::classifier; -use crate::classifier::V3Bin; -use crate::commits::upsert_commit; -use crate::source::KNOWN_FILE_SIZES_SUITES; -use crate::source::Source; -use crate::v2::V2Commit; -use crate::v2::V2FileSize; -use crate::v2::V2Record; -use crate::v2::canonical_scale_factor; -use crate::v2::index_commits; -use crate::v2::runtime_as_i64; -use crate::v2::value_as_f64; - -/// Per-table insert counts, plus skip / missing counts. -#[derive(Debug, Default, Clone)] -pub struct MigrationSummary { - /// Lines read from `data.json.gz`. - pub records_read: u64, - /// Rows successfully inserted into `query_measurements`. - pub query_inserted: u64, - /// Rows successfully inserted into `compression_times`. - pub compression_time_inserted: u64, - /// Rows successfully inserted into `compression_sizes`. - pub compression_size_inserted: u64, - /// Rows successfully inserted into `random_access_times`. - pub random_access_inserted: u64, - /// `file-sizes-*.json.gz` lines folded into `compression_sizes`. - pub file_size_inserted: u64, - /// Records the classifier returned `Unknown` for. - pub uncategorized: u64, - /// Top-level prefix histogram of uncategorised records, for triage. - pub uncategorized_prefixes: BTreeMap, - /// Records whose `commit_id` doesn't match any commit in `commits.jsonl`. - pub missing_commit: u64, - /// Warnings emitted while upserting commits (e.g. missing tree SHA). - pub commit_warnings: u64, - /// Records dropped because their `value` was missing or non-numeric. - pub skipped_no_value: u64, - /// Records the classifier returned `Skip(reason)` for. - pub skipped_intentional: u64, - /// Commits upserted into the `commits` dim table. - pub commits_inserted: u64, - /// Records dropped by dedup because their `measurement_id` collided - /// with a previously kept row. - pub deduped: u64, - /// Number of records dropped by dedup whose `value_ns` (or - /// `value_bytes` for compression_sizes' replace path) differed - /// from the kept row's. Non-zero is a smell worth investigating. - pub deduped_with_conflict: u64, - /// `file-sizes-*.json.gz` source files that failed to download / - /// decode / parse. Non-zero means the migrated DB has missing - /// compression-size history from at least one v2 source file; the - /// CLI fails by default in that case unless - /// `--allow-missing-file-sizes` is passed. - pub file_sizes_failed: u64, -} - -impl MigrationSummary { - /// Total `data.json.gz` records that landed in some v3 fact table. - pub fn total_inserted(&self) -> u64 { - self.query_inserted - + self.compression_time_inserted - + self.compression_size_inserted - + self.random_access_inserted - } - - /// Fraction of records that were uncategorized. The orchestrator - /// stops if this exceeds the documented 5% threshold. - pub fn uncategorized_fraction(&self) -> f64 { - if self.records_read == 0 { - return 0.0; - } - self.uncategorized as f64 / self.records_read as f64 - } -} - -/// Open or create a DuckDB at `path` and apply the v3 schema. The -/// migrator is a one-shot fresh load; the bulk-append flush is pure -/// insert (no `ON CONFLICT`), so any stale rows in `path` would clash -/// with the next run on the same primary keys. Delete both the -/// database file and its WAL companion up front so every run starts -/// from a known-empty state. -pub fn open_target_db(path: &Path) -> Result { - remove_if_exists(path)?; - let wal = wal_path(path); - remove_if_exists(&wal)?; - let conn = - Connection::open(path).with_context(|| format!("opening DuckDB at {}", path.display()))?; - // Apply the v3 schema. Drives off the per-fact-table `family::Family` - // registry the same way `vortex_bench_server::db::open` does - adding - // a sixth fact table only needs a new const there, not an edit here. - conn.execute_batch(COMMITS_DDL) - .context("applying commits dim DDL")?; - for fam in family::FAMILIES { - conn.execute_batch(fam.schema_ddl) - .with_context(|| format!("applying {} DDL", fam.table_name))?; - } - Ok(conn) -} - -fn remove_if_exists(path: &Path) -> Result<()> { - match std::fs::remove_file(path) { - Ok(()) => { - info!(path = %path.display(), "removed pre-existing target file"); - Ok(()) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e).with_context(|| format!("removing {}", path.display())), - } -} - -/// DuckDB writes its write-ahead log next to the database file with a -/// `.wal` suffix appended (e.g. `v3.duckdb` -> `v3.duckdb.wal`). -fn wal_path(path: &Path) -> std::path::PathBuf { - let mut name = path.as_os_str().to_owned(); - name.push(".wal"); - std::path::PathBuf::from(name) -} - -/// Run the whole migration: commits, data.json.gz, and every -/// file-sizes-*.json.gz under the source. -pub fn run(source: &Source, target: &Path) -> Result { - let mut conn = open_target_db(target)?; - let mut summary = MigrationSummary::default(); - - info!(source = %source.describe(), "Reading commits.json"); - let commits = read_commits(source)?; - info!(commits = commits.len(), "Loaded commits"); - summary.commits_inserted = upsert_all_commits(&mut conn, &commits, &mut summary)?; - - let mut q = QueryAccum::default(); - let mut ct = CompressionTimeAccum::default(); - let mut cs = CompressionSizeAccum::default(); - let mut ra = RandomAccessAccum::default(); - - info!("Migrating data.json.gz"); - migrate_data_jsonl( - source, - &commits, - &mut summary, - &mut q, - &mut ct, - &mut cs, - &mut ra, - )?; - info!(records = summary.records_read, "data.json.gz done"); - - for name in source.list_file_sizes()? { - info!(name = %name, "Migrating file-sizes"); - if let Err(e) = migrate_file_sizes(source, &name, &commits, &mut summary, &mut cs) { - warn!("file-sizes file {name} failed: {e:#}"); - summary.file_sizes_failed += 1; - } - } - - info!("Flushing accumulators to DuckDB"); - flush_all(&conn, q, ct, ra, cs, &mut summary)?; - - Ok(summary) -} - -/// Flush each accumulator's batch and bump the matching per-fact -/// summary counter only AFTER the flush succeeds. This way a flush -/// failure leaves the counter at zero (or its previous value) rather -/// than reporting rows that never landed in DuckDB. -fn flush_all( - conn: &Connection, - q: QueryAccum, - ct: CompressionTimeAccum, - ra: RandomAccessAccum, - cs: CompressionSizeAccum, - summary: &mut MigrationSummary, -) -> Result<()> { - let batch = build_query_batch(q)?; - let n = batch.num_rows() as u64; - flush(conn, "query_measurements", batch)?; - summary.query_inserted = n; - - let batch = build_compression_time_batch(ct)?; - let n = batch.num_rows() as u64; - flush(conn, "compression_times", batch)?; - summary.compression_time_inserted = n; - - let batch = build_random_access_batch(ra)?; - let n = batch.num_rows() as u64; - flush(conn, "random_access_times", batch)?; - summary.random_access_inserted = n; - - let batch = build_compression_size_batch(cs)?; - let n = batch.num_rows() as u64; - flush(conn, "compression_sizes", batch)?; - summary.compression_size_inserted = n; - - Ok(()) -} - -fn read_commits(source: &Source) -> Result> { - let reader = source.open_commits_jsonl()?; - let mut commits: Vec = Vec::new(); - for line in reader.lines() { - let line = line?; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match serde_json::from_str::(trimmed) { - Ok(c) => commits.push(c), - Err(e) => warn!("skipping malformed commits.json line: {e}"), - } - } - Ok(index_commits(commits)) -} - -fn upsert_all_commits( - conn: &mut Connection, - commits: &BTreeMap, - summary: &mut MigrationSummary, -) -> Result { - let tx = conn.transaction().context("begin commits transaction")?; - let mut count = 0u64; - for commit in commits.values() { - let outcome = upsert_commit(&tx, commit)?; - for w in outcome.warnings { - warn!("{w}"); - summary.commit_warnings += 1; - } - count += 1; - } - tx.commit().context("commit commits transaction")?; - Ok(count) -} - -/// Stream `data.json.gz` and push classified records into the -/// per-table accumulators. Dedup happens inside each accumulator's -/// `push` method by `measurement_id`. -fn migrate_data_jsonl( - source: &Source, - commits: &BTreeMap, - summary: &mut MigrationSummary, - q: &mut QueryAccum, - ct: &mut CompressionTimeAccum, - cs: &mut CompressionSizeAccum, - ra: &mut RandomAccessAccum, -) -> Result<()> { - let reader = source.open_data_jsonl()?; - let started = Instant::now(); - let mut last_log = Instant::now(); - for line in reader.lines() { - let line = line?; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - summary.records_read += 1; - let record: V2Record = match serde_json::from_str(trimmed) { - Ok(r) => r, - Err(e) => { - warn!("skipping malformed data.json line: {e}"); - continue; - } - }; - apply_v2_record(&record, commits, summary, q, ct, cs, ra); - if last_log.elapsed() >= Duration::from_secs(5) { - let elapsed = started.elapsed().as_secs_f64(); - let rate = summary.records_read as f64 / elapsed.max(0.001); - info!( - records = summary.records_read, - rate = format!("{rate:.0}/s"), - query = q.measurement_id.len(), - compression_time = ct.measurement_id.len(), - compression_size = cs.rows.len(), - random_access = ra.measurement_id.len(), - "migration progress", - ); - last_log = Instant::now(); - } - } - Ok(()) -} - -fn apply_v2_record( - record: &V2Record, - commits: &BTreeMap, - summary: &mut MigrationSummary, - q: &mut QueryAccum, - ct: &mut CompressionTimeAccum, - cs: &mut CompressionSizeAccum, - ra: &mut RandomAccessAccum, -) { - let Some(sha) = record.commit_id.clone() else { - summary.missing_commit += 1; - return; - }; - if !commits.contains_key(&sha) { - summary.missing_commit += 1; - return; - } - - let bin = match classifier::classify_outcome(record) { - classifier::Outcome::Bin(b) => b, - classifier::Outcome::Skip(_) => { - summary.skipped_intentional += 1; - return; - } - classifier::Outcome::Unknown => { - summary.uncategorized += 1; - let prefix = record.name.split('/').next().unwrap_or("").to_string(); - *summary.uncategorized_prefixes.entry(prefix).or_insert(0) += 1; - return; - } - }; - - let env_triple = record.env_triple.as_ref().and_then(|t| t.to_triple()); - let runtimes = record - .all_runtimes - .as_ref() - .map(|v| v.iter().filter_map(runtime_as_i64).collect::>()) - .unwrap_or_default(); - let value_f64 = match record.value.as_ref().and_then(value_as_f64) { - Some(v) => v, - None => { - summary.skipped_no_value += 1; - return; - } - }; - - match bin { - V3Bin::Query { - dataset, - dataset_variant, - scale_factor, - query_idx, - storage, - engine, - format, - } => { - let qm = QueryMeasurement { - commit_sha: sha, - dataset, - dataset_variant, - scale_factor, - query_idx, - storage, - engine, - format, - value_ns: value_f64 as i64, - all_runtimes_ns: runtimes, - peak_physical: None, - peak_virtual: None, - physical_delta: None, - virtual_delta: None, - env_triple, - }; - let mid = measurement_id_query(&qm); - q.push(mid, qm, summary); - } - V3Bin::CompressionTime { - dataset, - dataset_variant, - format, - op, - } => { - let ctr = CompressionTime { - commit_sha: sha, - dataset, - dataset_variant, - format, - op, - value_ns: value_f64 as i64, - all_runtimes_ns: runtimes, - env_triple, - }; - let mid = measurement_id_compression_time(&ctr); - ct.push(mid, ctr, summary); - } - V3Bin::CompressionSize { - dataset, - dataset_variant, - format, - } => { - let csr = CompressionSize { - commit_sha: sha, - dataset, - dataset_variant, - format, - value_bytes: value_f64 as i64, - }; - let mid = measurement_id_compression_size(&csr); - cs.push_replace(mid, csr, summary); - } - V3Bin::RandomAccess { dataset, format } => { - let rar = RandomAccessTime { - commit_sha: sha, - dataset, - format, - value_ns: value_f64 as i64, - all_runtimes_ns: runtimes, - env_triple, - }; - let mid = measurement_id_random_access(&rar); - ra.push(mid, rar, summary); - } - } -} - -fn migrate_file_sizes( - source: &Source, - name: &str, - commits: &BTreeMap, - summary: &mut MigrationSummary, - cs: &mut CompressionSizeAccum, -) -> Result<()> { - let reader = source.open_file_sizes(name)?; - // Prefix unknown-id fallbacks with `unknown:` so they're clearly - // labeled in the UI rather than masquerading as a dataset name. - let dataset_fallback = { - let stripped = name - .strip_prefix("file-sizes-") - .and_then(|s| s.strip_suffix(".json.gz")) - .unwrap_or(name); - if KNOWN_FILE_SIZES_SUITES.contains(&stripped) { - stripped.to_string() - } else { - format!("unknown:{stripped}") - } - }; - let started = Instant::now(); - let mut last_log = Instant::now(); - for line in reader.lines() { - let line = line?; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let sz: V2FileSize = match serde_json::from_str(trimmed) { - Ok(r) => r, - Err(e) => { - warn!("skipping malformed {name} line: {e}"); - continue; - } - }; - if !commits.contains_key(&sz.commit_id) { - summary.missing_commit += 1; - continue; - } - let dataset = if sz.benchmark.is_empty() { - dataset_fallback.clone() - } else { - sz.benchmark.clone() - }; - // Run SF through canonical_scale_factor so `"1"`, `"1.0"`, `"10"` - // and `"10.0"` collapse to one form, matching what - // `bin_compression_size` writes for the data.json.gz path. - let dataset_variant = canonical_scale_factor(sz.scale_factor.as_deref()); - let csr = CompressionSize { - commit_sha: sz.commit_id.clone(), - dataset, - dataset_variant, - format: sz.format.clone(), - value_bytes: sz.size_bytes, - }; - let mid = measurement_id_compression_size(&csr); - cs.push_sum(mid, csr); - summary.file_size_inserted += 1; - if last_log.elapsed() >= Duration::from_secs(5) { - let elapsed = started.elapsed().as_secs_f64(); - let rate = summary.file_size_inserted as f64 / elapsed.max(0.001); - info!( - name = %name, - file_sizes = summary.file_size_inserted, - rate = format!("{rate:.0}/s"), - "file-sizes progress", - ); - last_log = Instant::now(); - } - } - Ok(()) -} - -/// Append an Arrow `RecordBatch` to a DuckDB table via `Appender`. -fn flush(conn: &Connection, table: &str, batch: RecordBatch) -> Result<()> { - let mut app = conn - .appender(table) - .with_context(|| format!("opening appender for {table}"))?; - app.append_record_batch(batch) - .with_context(|| format!("appending record batch to {table}"))?; - drop(app); - Ok(()) -} - -/// Print the summary in a human-readable form. Returned by the CLI. -impl std::fmt::Display for MigrationSummary { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "Records read: {}", self.records_read)?; - writeln!(f, "Commits upserted: {}", self.commits_inserted)?; - writeln!(f, "Commit warnings: {}", self.commit_warnings)?; - writeln!(f, "Inserted (query): {}", self.query_inserted)?; - writeln!( - f, - "Inserted (compress t): {}", - self.compression_time_inserted - )?; - writeln!( - f, - "Inserted (compress s): {}", - self.compression_size_inserted - )?; - writeln!(f, "Inserted (random acc): {}", self.random_access_inserted)?; - writeln!(f, "Inserted (file sizes): {}", self.file_size_inserted)?; - writeln!(f, "Missing commit: {}", self.missing_commit)?; - writeln!(f, "Skipped (no value): {}", self.skipped_no_value)?; - writeln!(f, "Skipped (intentional): {}", self.skipped_intentional)?; - writeln!(f, "Deduplicated: {}", self.deduped)?; - writeln!(f, "Dedup w/ value diff: {}", self.deduped_with_conflict)?; - writeln!( - f, - "Uncategorized: {} ({:.2}%)", - self.uncategorized, - 100.0 * self.uncategorized_fraction() - )?; - if !self.uncategorized_prefixes.is_empty() { - let mut top: Vec<_> = self.uncategorized_prefixes.iter().collect(); - top.sort_by(|a, b| b.1.cmp(a.1)); - writeln!(f, "Top uncategorized prefixes:")?; - for (prefix, n) in top.iter().take(20) { - writeln!(f, " {prefix:>32} : {n}")?; - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use vortex_bench_server::records::QueryMeasurement; - - use super::*; - - fn open_db_without(table: &str) -> Result<(tempfile::TempDir, Connection)> { - let dir = tempfile::TempDir::new()?; - let path = dir.path().join("v3.duckdb"); - let conn = open_target_db(&path)?; - conn.execute_batch(&format!("DROP TABLE {table}"))?; - Ok((dir, conn)) - } - - fn one_query_row() -> QueryMeasurement { - QueryMeasurement { - commit_sha: "deadbeef".into(), - dataset: "clickbench".into(), - dataset_variant: None, - scale_factor: None, - query_idx: 7, - storage: "nvme".into(), - engine: "datafusion".into(), - format: "parquet".into(), - value_ns: 100, - all_runtimes_ns: vec![100], - peak_physical: None, - peak_virtual: None, - physical_delta: None, - virtual_delta: None, - env_triple: None, - } - } - - #[test] - fn flush_all_does_not_overcount_on_failure() -> Result<()> { - // Drop `compression_times` before flushing so the second - // flush in `flush_all` fails. The first (queries) succeeded, - // so its counter must be set; the failed table's counter and - // every later table's counter must stay at zero. - let (_dir, conn) = open_db_without("compression_times")?; - - let mut summary = MigrationSummary::default(); - let mut q = QueryAccum::default(); - let qm = one_query_row(); - let mid = vortex_bench_server::db::measurement_id_query(&qm); - q.push(mid, qm, &mut summary); - - let ct = CompressionTimeAccum::default(); - let ra = RandomAccessAccum::default(); - let cs = CompressionSizeAccum::default(); - - let result = flush_all(&conn, q, ct, ra, cs, &mut summary); - assert!(result.is_err(), "expected flush to fail on missing table"); - - assert_eq!( - summary.query_inserted, 1, - "query flushed before the failure must be counted" - ); - assert_eq!( - summary.compression_time_inserted, 0, - "failed flush must not bump the counter" - ); - assert_eq!(summary.random_access_inserted, 0, "later flushes never ran"); - assert_eq!( - summary.compression_size_inserted, 0, - "later flushes never ran" - ); - Ok(()) - } -} diff --git a/benchmarks-website/migrate/src/source.rs b/benchmarks-website/migrate/src/source.rs deleted file mode 100644 index acd53c7626e..00000000000 --- a/benchmarks-website/migrate/src/source.rs +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Streaming readers for v2's public S3 bucket. -//! -//! The bucket is `--no-sign-request`, so we fetch the underlying -//! HTTPS URL directly and stream-decompress with `flate2`. The -//! downloads are wrapped in [`reqwest::blocking`] to keep the read -//! path synchronous; the binary's hot path is single-threaded -//! per-source already (DuckDB is a single-writer). -//! -//! For tests and offline runs, [`Source::Local`](crate::source::Source::Local) accepts a local -//! directory of dumps; the migrator's `--source` flag picks the -//! variant. - -use std::fs::File; -use std::io::BufRead; -use std::io::BufReader; -use std::io::Read; -use std::path::Path; -use std::path::PathBuf; - -use anyhow::Context as _; -use anyhow::Result; -use flate2::read::GzDecoder; -use tracing::info; - -/// Public S3 bucket the live v2 server reads from. -pub const PUBLIC_BUCKET_BASE: &str = "https://vortex-ci-benchmark-results.s3.amazonaws.com"; - -/// Where to read the v2 dataset from. Either the public S3 bucket -/// (the live deployment) or a local directory of dumps. -#[derive(Debug, Clone)] -pub enum Source { - /// HTTPS GETs against `s3.amazonaws.com`. - PublicS3, - /// A directory containing `data.json.gz`, `commits.json`, and - /// `file-sizes-*.json.gz` files. - Local(PathBuf), -} - -impl Source { - /// Short human-readable description for log messages. - pub fn describe(&self) -> String { - match self { - Source::PublicS3 => "public S3 bucket".to_string(), - Source::Local(p) => format!("local dir {}", p.display()), - } - } - - /// Open `data.json.gz` for streaming, decompressing on the fly. - pub fn open_data_jsonl(&self) -> Result> { - let stream = self.open_raw("data.json.gz")?; - Ok(Box::new(BufReader::new(GzDecoder::new(stream)))) - } - - /// Open `commits.json` (uncompressed). - pub fn open_commits_jsonl(&self) -> Result> { - let stream = self.open_raw("commits.json")?; - Ok(Box::new(BufReader::new(stream))) - } - - /// Enumerate `file-sizes-*.json.gz` files. For local sources this - /// is a directory glob; for the public bucket we hit the documented - /// suite ids. - pub fn list_file_sizes(&self) -> Result> { - match self { - Source::Local(dir) => { - let mut out = Vec::new(); - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let name = entry.file_name(); - let s = name.to_string_lossy(); - if s.starts_with("file-sizes-") && s.ends_with(".json.gz") { - out.push(s.into_owned()); - } - } - out.sort(); - Ok(out) - } - Source::PublicS3 => { - // The S3 bucket's ListObjects is denied for unsigned - // requests, so we hit the documented per-suite keys - // emitted by `.github/workflows/sql-benchmarks.yml`. - Ok(KNOWN_FILE_SIZES_SUITES - .iter() - .map(|id| format!("file-sizes-{id}.json.gz")) - .collect()) - } - } - } - - /// Open one `file-sizes-*.json.gz` for streaming. - pub fn open_file_sizes(&self, name: &str) -> Result> { - let stream = self.open_raw(name)?; - Ok(Box::new(BufReader::new(GzDecoder::new(stream)))) - } - - fn open_raw(&self, name: &str) -> Result> { - match self { - Source::Local(dir) => open_local(&dir.join(name)), - Source::PublicS3 => open_s3(name), - } - } -} - -fn open_local(path: &Path) -> Result> { - let f = File::open(path).with_context(|| format!("opening {}", path.display()))?; - Ok(Box::new(f)) -} - -fn open_s3(name: &str) -> Result> { - let url = format!("{PUBLIC_BUCKET_BASE}/{name}"); - info!(url = %url, "GET"); - let resp = reqwest::blocking::get(&url).with_context(|| format!("GET {url}"))?; - if !resp.status().is_success() { - anyhow::bail!("GET {url} returned {}", resp.status()); - } - Ok(Box::new(resp)) -} - -/// Suite IDs we know publish a `file-sizes-{id}.json.gz` to S3. -/// -/// Source of truth: the `matrix.id` values in -/// `.github/workflows/sql-benchmarks.yml`'s `benchmark_matrix` default. -/// The post-bench `file-sizes` step uploads `file-sizes-${{ matrix.id -/// }}.json.gz`, so this list must match those IDs verbatim. Adding a -/// new matrix entry to that workflow means adding the same ID here. -pub(crate) const KNOWN_FILE_SIZES_SUITES: &[&str] = &[ - "clickbench-nvme", - "tpch-nvme", - "tpch-s3", - "tpch-nvme-10", - "tpch-s3-10", - "tpcds-nvme", - "statpopgen", - "fineweb", - "fineweb-s3", - "polarsignals", -]; diff --git a/benchmarks-website/migrate/src/v2.rs b/benchmarks-website/migrate/src/v2.rs deleted file mode 100644 index 79785ba1fd4..00000000000 --- a/benchmarks-website/migrate/src/v2.rs +++ /dev/null @@ -1,197 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Wire shapes of the v2 benchmark dataset on S3. -//! -//! These types capture only the fields the migrator reads. v2 records -//! are serialized by `vortex-bench` (see `vortex-bench/src/measurements.rs`) -//! and by older non-Rust scripts; the union of fields is loose, so we -//! deserialize permissively (`serde(default)`, untyped `serde_json::Value` -//! for the polymorphic `dataset` field). - -use std::collections::BTreeMap; - -use serde::Deserialize; - -/// One JSONL line of `data.json.gz`. -/// -/// The shape is the union of every emitter's output. Most fields are -/// optional because different benches emit different subsets. -#[derive(Debug, Clone, Deserialize)] -pub struct V2Record { - /// Slash-separated benchmark identifier (e.g. `tpch_q01/datafusion:vortex-file-compressed`). - /// The classifier parses this string to recover dim values. - pub name: String, - /// 40-hex commit SHA. Present on every well-formed v2 record. - #[serde(default)] - pub commit_id: Option, - /// v2 unit string (`ns`, `bytes`, `ratio`, ...). Not used for routing — - /// the classifier picks the v3 fact table from the `name` prefix instead. - #[serde(default)] - pub unit: Option, - /// Polymorphic value — emitters wrote both numbers and stringified - /// numbers. Use [`value_as_f64`] to normalize. - #[serde(default)] - pub value: Option, - /// Storage backend the run targeted (`S3` or `NVMe`, mixed case in v2). - #[serde(default)] - pub storage: Option, - /// Polymorphic dataset block — sometimes a string, sometimes an object - /// keyed by suite name with a `scale_factor` inside (use - /// [`dataset_scale_factor`]). - #[serde(default)] - pub dataset: Option, - /// Per-iteration runtimes; same numeric polymorphism as `value`. - #[serde(default)] - pub all_runtimes: Option>, - /// Host environment triple block. - #[serde(default)] - pub env_triple: Option, -} - -/// `dataset` in v2 records is sometimes a string, sometimes an object -/// keyed by suite name (`{ "tpch": { "scale_factor": "10" } }`). -/// This helper looks up the scale factor for a given suite without -/// assuming a particular shape. -pub fn dataset_scale_factor(dataset: &serde_json::Value, key: &str) -> Option { - let obj = dataset.as_object()?; - let entry = obj.get(key)?; - let sf = entry.get("scale_factor")?; - match sf { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Number(n) => Some(n.to_string()), - _ => None, - } -} - -/// Canonicalize a v2 scale-factor string for use in `dataset_variant`. -/// -/// v2 emitters wrote scale factors as either `"1"`, `"1.0"`, `"10"`, or -/// `"10.0"` for the same logical SF, so the data.json.gz path -/// (`bin_compression_size`) and the file-sizes-*.json.gz path -/// (`migrate_file_sizes`) would otherwise produce different -/// `dataset_variant` strings and never collapse onto the same -/// `measurement_id`. Parse to f64 and format with no trailing zeros so -/// every shape collapses to one canonical form (`"1"`, `"10"`, `"0.1"`). -/// SF=1 is the implicit default and folds to `None`. -pub fn canonical_scale_factor(raw: Option<&str>) -> Option { - let s = raw?.trim(); - if s.is_empty() { - return None; - } - let value: f64 = s.parse().ok()?; - if value == 1.0 { - return None; - } - Some(format!("{value}")) -} - -/// Best-effort numeric coercion for the polymorphic `value` field. -pub fn value_as_f64(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::Number(n) => n.as_f64(), - serde_json::Value::String(s) => s.parse().ok(), - _ => None, - } -} - -/// Best-effort coercion of a runtime entry to nanoseconds. -pub fn runtime_as_i64(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Some(i) - } else { - n.as_f64().map(|f| f as i64) - } - } - serde_json::Value::String(s) => s.parse().ok(), - _ => None, - } -} - -/// Triple block as emitted by `vortex-bench`'s `--gh-json` path. v2 -/// stored it as an object; we serialize it back out as `arch-os-env`. -#[derive(Debug, Clone, Deserialize)] -pub struct V2EnvTriple { - /// Host CPU architecture (e.g. `x86_64`). - #[serde(default)] - pub architecture: Option, - /// Operating system name (e.g. `linux`). - #[serde(default)] - pub operating_system: Option, - /// Host environment label (e.g. `gnu`). - #[serde(default)] - pub environment: Option, -} - -impl V2EnvTriple { - /// Format as the `arch-os-env` triple used by v3's `env_triple` column. - pub fn to_triple(&self) -> Option { - let arch = self.architecture.as_deref()?; - let os = self.operating_system.as_deref()?; - let env = self.environment.as_deref()?; - Some(format!("{arch}-{os}-{env}")) - } -} - -/// One JSONL line of `commits.json`. -#[derive(Debug, Clone, Deserialize)] -pub struct V2Commit { - /// 40-hex commit SHA (the v2 schema named this `id`, not `commit_sha`). - pub id: String, - /// RFC 3339 commit timestamp; required for the v3 row but tolerated as - /// missing in the source dump. - #[serde(default)] - pub timestamp: Option, - /// Full commit message. - #[serde(default)] - pub message: Option, - /// Author block. - #[serde(default)] - pub author: Option, - /// Committer block. - #[serde(default)] - pub committer: Option, - /// Git tree SHA. - #[serde(default)] - pub tree_id: Option, - /// GitHub commit URL. - #[serde(default)] - pub url: Option, -} - -/// Author or committer block on a v2 commit record. -#[derive(Debug, Clone, Deserialize)] -pub struct V2Person { - /// Display name. - #[serde(default)] - pub name: Option, - /// Email address. - #[serde(default)] - pub email: Option, -} - -/// One JSONL line of `file-sizes-*.json.gz` produced by -/// `scripts/capture-file-sizes.py`. -#[derive(Debug, Clone, Deserialize)] -pub struct V2FileSize { - /// 40-hex commit SHA. - pub commit_id: String, - /// Compression dataset name (`benchmark` is the v2 field name). - pub benchmark: String, - /// TPC SF as a string when relevant. - #[serde(default)] - pub scale_factor: Option, - /// Format the file was produced in. - pub format: String, - /// Path of the underlying file (e.g. `lineitem.parquet`); informational. - pub file: String, - /// Size in bytes; summed across files in the same `(commit, dataset, format)`. - pub size_bytes: i64, -} - -/// Build a sha-keyed map of commits. -pub fn index_commits(commits: Vec) -> BTreeMap { - commits.into_iter().map(|c| (c.id.clone(), c)).collect() -} diff --git a/benchmarks-website/migrate/src/verify.rs b/benchmarks-website/migrate/src/verify.rs deleted file mode 100644 index c855904a5a5..00000000000 --- a/benchmarks-website/migrate/src/verify.rs +++ /dev/null @@ -1,360 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Structural diff between a migrated v3 DuckDB and the live v2 -//! `/api/metadata` endpoint. -//! -//! Compares group / chart structure only; values aren't compared -//! because v2 converts ns → ms and bytes → MiB on read while v3 -//! stores raw and the chart query divides. Group/chart structural -//! equivalence is enough to spot classifier regressions before -//! cutover. - -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::path::Path; - -use anyhow::Context as _; -use anyhow::Result; -use duckdb::Connection; -use serde::Deserialize; - -use crate::classifier::QUERY_SUITES; - -/// Result of one `verify` run. -#[derive(Debug, Default)] -pub struct VerifyReport { - /// Group display names present in both v2 and v3. - pub matched_groups: Vec, - /// Group display names that exist in v3 but not v2. - pub only_in_v3: Vec, - /// Group display names that exist in v2 but not v3 — these gate the CLI's - /// non-zero exit. - pub only_in_v2: Vec, - /// Per-group chart-count diffs for groups present on both sides. - pub chart_diffs: Vec, -} - -/// One group's chart-count divergence between v2 and v3, captured when the -/// group is structurally present on both sides but the counts differ. -#[derive(Debug, Clone)] -pub struct ChartDiff { - /// Group display name. - pub group: String, - /// Number of charts v2 reported for this group. - pub v2_count: usize, - /// Number of charts the migrated v3 DuckDB has for this group. - pub v3_count: usize, -} - -impl VerifyReport { - /// True if every v2 group is represented in v3. The CLI's exit - /// code reflects this. - pub fn v2_groups_covered(&self) -> bool { - self.only_in_v2.is_empty() - } -} - -impl std::fmt::Display for VerifyReport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "Groups in both v2 and v3:")?; - for g in &self.matched_groups { - writeln!(f, " + {g}")?; - } - if !self.only_in_v2.is_empty() { - writeln!(f, "Groups only in v2 (regression candidates):")?; - for g in &self.only_in_v2 { - writeln!(f, " - {g}")?; - } - } - if !self.only_in_v3.is_empty() { - writeln!(f, "Groups only in v3:")?; - for g in &self.only_in_v3 { - writeln!(f, " + {g}")?; - } - } - if !self.chart_diffs.is_empty() { - writeln!(f, "Chart count diffs:")?; - for d in &self.chart_diffs { - writeln!( - f, - " {} : v2={} v3={} (delta={})", - d.group, - d.v2_count, - d.v3_count, - d.v3_count as i64 - d.v2_count as i64, - )?; - } - } - Ok(()) - } -} - -/// v2's `/api/metadata` reply — only the fields we need. -#[derive(Debug, Deserialize)] -struct V2Metadata { - groups: BTreeMap, -} - -#[derive(Debug, Deserialize)] -struct V2GroupMeta { - #[serde(default)] - charts: Vec, -} - -#[derive(Debug, Deserialize)] -struct V2ChartMeta { - #[serde(default)] - name: String, -} - -/// Open the migrated DuckDB at `duckdb_path`, fetch `/api/metadata`, -/// and produce a structural diff. -pub fn run(v2_server: &str, duckdb_path: &Path) -> Result { - let v3 = collect_v3_groups(duckdb_path)?; - let v2 = fetch_v2_metadata(v2_server)?; - Ok(diff(&v2, &v3)) -} - -fn collect_v3_groups(duckdb_path: &Path) -> Result>> { - let conn = Connection::open(duckdb_path) - .with_context(|| format!("opening DuckDB at {}", duckdb_path.display()))?; - let mut groups: BTreeMap> = BTreeMap::new(); - - // query_measurements: chart per (dataset, query_idx); group per - // (dataset, dataset_variant, scale_factor, storage). We want v2 - // group display names so the verifier can compare apples to - // apples, so we re-format them here using the same suite table. - let mut stmt = conn.prepare( - r#" - SELECT dataset, dataset_variant, scale_factor, storage, query_idx - FROM query_measurements - GROUP BY dataset, dataset_variant, scale_factor, storage, query_idx - "#, - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, String>(3)?, - row.get::<_, i32>(4)?, - )) - })?; - for row in rows { - let (dataset, _variant, sf, storage, query_idx) = row?; - let group_name = display_query_group(&dataset, sf.as_deref(), &storage); - let chart_name = chart_name_query(&dataset, query_idx); - groups - .entry(group_name) - .or_default() - .insert(normalize_chart(&chart_name)); - } - - // compression_times: group "Compression", charts per dataset. - let mut stmt = conn.prepare( - r#" - SELECT dataset, format, op - FROM compression_times - GROUP BY dataset, format, op - "#, - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })?; - for row in rows { - let (dataset, format, op) = row?; - let chart = chart_name_compression_time(&format, &op, &dataset); - groups - .entry("Compression".to_string()) - .or_default() - .insert(normalize_chart(&chart)); - } - - let mut stmt = conn.prepare( - r#" - SELECT dataset, format - FROM compression_sizes - GROUP BY dataset, format - "#, - )?; - let rows = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - for row in rows { - let (_dataset, format) = row?; - let chart = chart_name_compression_size(&format); - groups - .entry("Compression Size".to_string()) - .or_default() - .insert(normalize_chart(&chart)); - } - - let mut stmt = conn.prepare( - r#" - SELECT DISTINCT dataset - FROM random_access_times - "#, - )?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - for row in rows { - let dataset = row?; - groups - .entry("Random Access".to_string()) - .or_default() - .insert(normalize_chart(&dataset)); - } - - Ok(groups) -} - -fn fetch_v2_metadata(server: &str) -> Result>> { - let url = format!("{}/api/metadata", server.trim_end_matches('/')); - let body = reqwest::blocking::get(&url) - .with_context(|| format!("GET {url}"))? - .error_for_status() - .with_context(|| format!("non-2xx from {url}"))? - .json::() - .with_context(|| format!("parsing {url} as v2 /api/metadata"))?; - let mut out: BTreeMap> = BTreeMap::new(); - for (name, group) in body.groups { - let charts = group - .charts - .into_iter() - .map(|c| normalize_chart(&c.name)) - .collect(); - out.insert(name, charts); - } - Ok(out) -} - -fn diff( - v2: &BTreeMap>, - v3: &BTreeMap>, -) -> VerifyReport { - let mut report = VerifyReport::default(); - let v2_keys: BTreeSet<&String> = v2.keys().collect(); - let v3_keys: BTreeSet<&String> = v3.keys().collect(); - for g in v2_keys.intersection(&v3_keys) { - report.matched_groups.push((**g).clone()); - let v2_charts = &v2[*g]; - let v3_charts = &v3[*g]; - if v2_charts.len() != v3_charts.len() { - report.chart_diffs.push(ChartDiff { - group: (**g).clone(), - v2_count: v2_charts.len(), - v3_count: v3_charts.len(), - }); - } - } - for g in v3_keys.difference(&v2_keys) { - report.only_in_v3.push((**g).clone()); - } - for g in v2_keys.difference(&v3_keys) { - report.only_in_v2.push((**g).clone()); - } - report.matched_groups.sort(); - report.only_in_v3.sort(); - report.only_in_v2.sort(); - report -} - -fn display_query_group(dataset: &str, scale_factor: Option<&str>, storage: &str) -> String { - let suite = QUERY_SUITES - .iter() - .find(|s| s.prefix.eq_ignore_ascii_case(dataset)) - .copied(); - match suite { - Some(suite) if suite.fan_out => { - let storage_disp = match storage { - "s3" | "S3" => "S3", - _ => "NVMe", - }; - let sf = scale_factor.unwrap_or("1"); - format!("{} ({}) (SF={})", suite.display_name, storage_disp, sf) - } - Some(suite) => suite.display_name.to_string(), - None => format!("{dataset} ({storage})"), - } -} - -fn chart_name_query(dataset: &str, query_idx: i32) -> String { - let suite = QUERY_SUITES - .iter() - .find(|s| s.prefix.eq_ignore_ascii_case(dataset)) - .copied(); - match suite { - Some(suite) => format!("{} Q{}", suite.query_prefix, query_idx), - None => format!("{} Q{}", dataset.to_uppercase(), query_idx), - } -} - -fn chart_name_compression_time(format: &str, op: &str, _dataset: &str) -> String { - // Re-derive the v2 chart name (the metric, not the dataset) so we - // can compare. v2's chart axis is the metric; series is the - // dataset. v3 inverts that. For structural comparison, we project - // back to v2's per-chart key. - match (format, op) { - ("vortex-file-compressed", "encode") => "COMPRESS TIME".into(), - ("vortex-file-compressed", "decode") => "DECOMPRESS TIME".into(), - ("parquet", "encode") => "PARQUET RS ZSTD COMPRESS TIME".into(), - ("parquet", "decode") => "PARQUET RS ZSTD DECOMPRESS TIME".into(), - ("lance", "encode") => "LANCE COMPRESS TIME".into(), - ("lance", "decode") => "LANCE DECOMPRESS TIME".into(), - _ => format!("{} {} TIME", format.to_uppercase(), op.to_uppercase()), - } -} - -fn chart_name_compression_size(format: &str) -> String { - match format { - "vortex-file-compressed" => "VORTEX SIZE".into(), - "parquet" => "PARQUET SIZE".into(), - "lance" => "LANCE SIZE".into(), - _ => format!("{} SIZE", format.to_uppercase()), - } -} - -/// Strip casing and `_-` differences between v2 and v3 chart names. -/// v2 displays uppercase; v3 stores raw values. Comparing in this -/// canonical form is enough for structural verification. -fn normalize_chart(s: &str) -> String { - s.trim() - .to_uppercase() - .replace(['_', '-'], " ") - .split_whitespace() - .collect::>() - .join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_chart_canonicalizes() { - assert_eq!(normalize_chart("taxi/take"), "TAXI/TAKE"); - assert_eq!(normalize_chart("TAXI/TAKE"), "TAXI/TAKE"); - assert_eq!(normalize_chart("tpc-h q1"), "TPC H Q1"); - assert_eq!(normalize_chart("tpc h q1"), "TPC H Q1"); - } - - #[test] - fn display_query_group_handles_fan_out() { - assert_eq!( - display_query_group("tpch", Some("10"), "s3"), - "TPC-H (S3) (SF=10)" - ); - assert_eq!( - display_query_group("tpch", Some("100"), "nvme"), - "TPC-H (NVMe) (SF=100)" - ); - assert_eq!( - display_query_group("clickbench", None, "nvme"), - "Clickbench" - ); - } -} diff --git a/benchmarks-website/migrate/tests/classifier.rs b/benchmarks-website/migrate/tests/classifier.rs deleted file mode 100644 index 71e97cb6c9a..00000000000 --- a/benchmarks-website/migrate/tests/classifier.rs +++ /dev/null @@ -1,531 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Classifier behavior pinned by representative v2 names from each -//! group in `benchmarks-website/server.js`'s `getGroup`. - -use rstest::rstest; -use serde_json::json; -use vortex_bench_migrate::classifier::Outcome; -use vortex_bench_migrate::classifier::Skip; -use vortex_bench_migrate::classifier::V3Bin; -use vortex_bench_migrate::classifier::classify; -use vortex_bench_migrate::classifier::classify_outcome; -use vortex_bench_migrate::classifier::format_query; -use vortex_bench_migrate::classifier::rename_engine; -use vortex_bench_migrate::v2::V2Record; - -fn record(name: &str) -> V2Record { - V2Record { - name: name.to_string(), - commit_id: Some("deadbeef".into()), - unit: Some("ns".into()), - value: Some(json!(123)), - storage: None, - dataset: None, - all_runtimes: None, - env_triple: None, - } -} - -fn record_with_storage_and_sf(name: &str, storage: &str, suite: &str, sf: &str) -> V2Record { - let mut r = record(name); - r.storage = Some(storage.into()); - r.dataset = Some(json!({ suite: { "scale_factor": sf } })); - r -} - -#[rstest] -#[case::clickbench( - "clickbench_q07/datafusion:parquet", - V3Bin::Query { - dataset: "clickbench".into(), - dataset_variant: None, - scale_factor: None, - query_idx: 7, - storage: "nvme".into(), - engine: "datafusion".into(), - format: "parquet".into(), - }, -)] -#[case::clickbench_vortex_renamed( - "clickbench_q12/datafusion:vortex-file-compressed", - V3Bin::Query { - dataset: "clickbench".into(), - dataset_variant: None, - scale_factor: None, - query_idx: 12, - storage: "nvme".into(), - engine: "datafusion".into(), - format: "vortex-file-compressed".into(), - }, -)] -#[case::statpopgen( - "statpopgen_q3/datafusion:parquet", - V3Bin::Query { - dataset: "statpopgen".into(), - dataset_variant: None, - scale_factor: None, - query_idx: 3, - storage: "nvme".into(), - engine: "datafusion".into(), - format: "parquet".into(), - }, -)] -#[case::polarsignals( - "polarsignals_q1/duckdb:parquet", - V3Bin::Query { - dataset: "polarsignals".into(), - dataset_variant: None, - scale_factor: None, - query_idx: 1, - storage: "nvme".into(), - engine: "duckdb".into(), - format: "parquet".into(), - }, -)] -fn non_fan_out_query_records(#[case] name: &str, #[case] expected: V3Bin) { - let r = record(name); - assert_eq!(classify(&r), Some(expected)); -} - -#[rstest] -#[case::tpch_s3_sf100( - "tpch_q01/datafusion:parquet", - "S3", - "tpch", - "100", - V3Bin::Query { - dataset: "tpch".into(), - dataset_variant: None, - scale_factor: Some("100".into()), - query_idx: 1, - storage: "s3".into(), - engine: "datafusion".into(), - format: "parquet".into(), - }, -)] -#[case::tpch_nvme_sf1( - "tpch_q22/duckdb:vortex-file-compressed", - "NVMe", - "tpch", - "1", - V3Bin::Query { - dataset: "tpch".into(), - dataset_variant: None, - scale_factor: Some("1".into()), - query_idx: 22, - storage: "nvme".into(), - engine: "duckdb".into(), - format: "vortex-file-compressed".into(), - }, -)] -#[case::tpcds_nvme_sf10( - "tpcds_q05/datafusion:vortex-file-compressed", - "NVMe", - "tpcds", - "10", - V3Bin::Query { - dataset: "tpcds".into(), - dataset_variant: None, - scale_factor: Some("10".into()), - query_idx: 5, - storage: "nvme".into(), - engine: "datafusion".into(), - format: "vortex-file-compressed".into(), - }, -)] -fn fan_out_query_records( - #[case] name: &str, - #[case] storage: &str, - #[case] suite: &str, - #[case] sf: &str, - #[case] expected: V3Bin, -) { - let r = record_with_storage_and_sf(name, storage, suite, sf); - assert_eq!(classify(&r), Some(expected)); -} - -#[rstest] -#[case::random_access_4_part( - "random-access/taxi/take/parquet-tokio-local-disk", - V3Bin::RandomAccess { - dataset: "taxi/take".into(), - format: "parquet".into(), - }, -)] -#[case::random_access_4_part_vortex( - "random-access/chimp/take/vortex-tokio-local-disk", - V3Bin::RandomAccess { - dataset: "chimp/take".into(), - format: "vortex-file-compressed".into(), - }, -)] -#[case::random_access_4_part_lance( - "random-access/taxi/take/lance-tokio-local-disk", - V3Bin::RandomAccess { - dataset: "taxi/take".into(), - format: "lance".into(), - }, -)] -fn random_access_records(#[case] name: &str, #[case] expected: V3Bin) { - let r = record(name); - assert_eq!(classify(&r), Some(expected)); -} - -#[rstest] -#[case::compress_time_vortex( - "compress time/clickbench", - V3Bin::CompressionTime { - dataset: "clickbench".into(), - dataset_variant: None, - format: "vortex-file-compressed".into(), - op: "encode".into(), - }, -)] -#[case::decompress_time_vortex( - "decompress time/tpch_lineitem", - V3Bin::CompressionTime { - dataset: "tpch_lineitem".into(), - dataset_variant: None, - format: "vortex-file-compressed".into(), - op: "decode".into(), - }, -)] -#[case::parquet_compress( - "parquet_rs-zstd compress time/clickbench", - V3Bin::CompressionTime { - dataset: "clickbench".into(), - dataset_variant: None, - format: "parquet".into(), - op: "encode".into(), - }, -)] -#[case::lance_decompress( - "lance decompress time/clickbench", - V3Bin::CompressionTime { - dataset: "clickbench".into(), - dataset_variant: None, - format: "lance".into(), - op: "decode".into(), - }, -)] -fn compression_time_records(#[case] name: &str, #[case] expected: V3Bin) { - let r = record(name); - assert_eq!(classify(&r), Some(expected)); -} - -#[rstest] -#[case::vortex_size( - "vortex size/clickbench", - V3Bin::CompressionSize { - dataset: "clickbench".into(), - dataset_variant: None, - format: "vortex-file-compressed".into(), - }, -)] -#[case::vortex_file_compressed_size_normalizes( - "vortex-file-compressed size/clickbench", - V3Bin::CompressionSize { - dataset: "clickbench".into(), - dataset_variant: None, - format: "vortex-file-compressed".into(), - }, -)] -#[case::parquet_size( - "parquet size/clickbench", - V3Bin::CompressionSize { - dataset: "clickbench".into(), - dataset_variant: None, - format: "parquet".into(), - }, -)] -#[case::lance_size( - "lance size/tpch_lineitem", - V3Bin::CompressionSize { - dataset: "tpch_lineitem".into(), - dataset_variant: None, - format: "lance".into(), - }, -)] -fn compression_size_records(#[case] name: &str, #[case] expected: V3Bin) { - let r = record(name); - assert_eq!(classify(&r), Some(expected)); -} - -#[rstest] -#[case::ratio_vortex_parquet("vortex:parquet-zstd ratio compress time/clickbench")] -#[case::ratio_vortex_lance("vortex:lance ratio decompress time/clickbench")] -#[case::ratio_size_vortex_parquet("vortex:parquet-zstd size/clickbench")] -#[case::ratio_size_vortex_raw("vortex:raw size/clickbench")] -#[case::throughput("compress throughput/clickbench")] -#[case::nonsense_prefix("not-a-known-bench/series")] -#[case::random_access_3_part("random-access/taxi/parquet-tokio-local-disk")] -fn unmapped_records_yield_none(#[case] name: &str) { - let r = record(name); - assert_eq!( - classify(&r), - None, - "expected {name:?} to classify as None (drop)", - ); -} - -#[rstest] -#[case::parquet_2_part( - "random-access/parquet-tokio-local-disk", - V3Bin::RandomAccess { - dataset: "taxi".into(), - format: "parquet".into(), - }, -)] -#[case::vortex_2_part( - "random-access/vortex-tokio-local-disk", - V3Bin::RandomAccess { - dataset: "taxi".into(), - format: "vortex-file-compressed".into(), - }, -)] -#[case::lance_2_part( - "random-access/lance-tokio-local-disk", - V3Bin::RandomAccess { - dataset: "taxi".into(), - format: "lance".into(), - }, -)] -fn random_access_2_part_legacy_recovered_as_taxi(#[case] name: &str, #[case] expected: V3Bin) { - // The 2-part shape `random-access/-tokio-local-disk` is - // emitted by `random-access-bench`'s legacy taxi run (no - // `AccessPattern`, see `measurement_name` in - // `benchmarks/random-access-bench/src/main.rs`). The live v3 - // emitter writes `dataset="taxi"` for those measurements, so the - // historical 2-part records on S3 must land in the same v3 - // chart instead of being dropped as `UnsupportedShape`. - let r = record(name); - assert_eq!( - classify(&r), - Some(expected), - "2-part legacy random-access must recover as dataset=taxi" - ); -} - -#[rstest] -#[case::parquet_footer("random-access/parquet-tokio-local-disk-footer")] -#[case::vortex_footer("random-access/vortex-tokio-local-disk-footer")] -#[case::lance_footer("random-access/lance-tokio-local-disk-footer")] -fn random_access_2_part_footer_is_deprecated(#[case] name: &str) { - // The reopen-mode `-footer` variant is a different access pattern - // (file is reopened per take). The live v3 emitter passes the - // bare `format.name()` for both reopen and cached, so it can't - // distinguish them on the wire. Keep migration consistent with - // that by routing `-footer` 2-part records to Skip::Deprecated - // (they don't strip clean to a v3-allowlisted format). - let r = record(name); - assert!( - matches!(classify_outcome(&r), Outcome::Skip(Skip::Deprecated)), - "2-part `-footer` random-access must be Skip::Deprecated" - ); -} - -#[rstest] -#[case::parquet_footer("random-access/taxi/correlated/parquet-tokio-local-disk-footer")] -#[case::vortex_footer("random-access/feature-vectors/uniform/vortex-tokio-local-disk-footer")] -#[case::lance_footer("random-access/nested-structs/correlated/lance-tokio-local-disk-footer")] -fn random_access_4_part_footer_is_deprecated(#[case] name: &str) { - // Same reasoning as 2-part `-footer`: the format string ends in - // `-tokio-local-disk-footer`, the strip_suffix doesn't match, and - // the unstripped value fails the V3_FORMATS allowlist. - let r = record(name); - assert!( - matches!(classify_outcome(&r), Outcome::Skip(Skip::Deprecated)), - "4-part `-footer` random-access must be Skip::Deprecated" - ); -} - -#[test] -fn parquet_zstd_size_is_deprecated() { - // `parquet-zstd` is not on the v3 emitter's format allowlist, so - // historical `parquet-zstd size/...` records bucket under - // Skip::Deprecated and don't render as orphan charts in v3. - let r = record("parquet-zstd size/clickbench"); - assert!(matches!( - classify_outcome(&r), - Outcome::Skip(Skip::Deprecated) - )); -} - -#[test] -fn vortex_parquet_zstd_ratio_is_intentional_skip() { - let r = record("vortex:parquet-zstd ratio compress time/clickbench"); - assert!(matches!( - classify_outcome(&r), - Outcome::Skip(Skip::DerivedRatio) - )); -} - -#[test] -fn vortex_parquet_zst_typo_ratio_is_intentional_skip() { - // `parquet-zst` (no trailing `d`) was emitted by some v2 runs. - // Both spellings should classify as derived ratios. - for name in [ - "vortex:parquet-zst ratio compress time/clickbench", - "vortex:parquet-zst ratio decompress time/clickbench", - ] { - let r = record(name); - assert!( - matches!(classify_outcome(&r), Outcome::Skip(Skip::DerivedRatio)), - "{name:?} should be DerivedRatio", - ); - } -} - -#[test] -fn throughput_is_intentional_skip() { - let r = record("compress throughput/clickbench"); - assert!(matches!( - classify_outcome(&r), - Outcome::Skip(Skip::Throughput) - )); -} - -#[test] -fn unknown_prefix_is_unknown() { - let r = record("not-a-known-bench/series"); - assert!(matches!(classify_outcome(&r), Outcome::Unknown)); -} - -#[test] -fn gharchive_q00_is_deprecated() { - // gharchive isn't on the v3 query-suite allowlist, so historical - // gharchive query records bucket as Skip::Deprecated. - let r = record("gharchive_q00/datafusion:parquet"); - assert!(matches!( - classify_outcome(&r), - Outcome::Skip(Skip::Deprecated) - )); -} - -#[test] -fn fineweb_q00_classifies() { - // fineweb is on V3_QUERY_SUITES (still emitted by v3 CI per - // .github/workflows/sql-benchmarks.yml's `fineweb` matrix entry), - // so historical fineweb records ingest like any other suite. - let r = record("fineweb_q00/datafusion:parquet"); - assert!(matches!( - classify_outcome(&r), - Outcome::Bin(V3Bin::Query { .. }) - )); -} - -#[test] -fn memory_record_is_historical_memory_skip() { - // v2 emitted `_q_memory/:` records that - // carry top-level memory fields V2Record doesn't deserialize. - // Skip them with a known variant so they don't trip the 5% gate. - let r = record("clickbench_q07_memory/datafusion:parquet"); - assert!(matches!( - classify_outcome(&r), - Outcome::Skip(Skip::HistoricalMemory) - )); -} - -#[test] -fn tpch_compression_size_carries_scale_factor() { - // The data.json.gz "vortex size/tpch" path needs to derive - // dataset_variant from the v2 record's `dataset` object, the same - // way the file-sizes path does. Otherwise SF=10 rows from the two - // sources never collide on `mid` and produce duplicate rows. - let mut r = record("vortex size/tpch"); - r.dataset = Some(serde_json::json!({ "tpch": { "scale_factor": "10" } })); - let outcome = classify_outcome(&r); - let Outcome::Bin(V3Bin::CompressionSize { - dataset, - dataset_variant, - format, - }) = outcome - else { - panic!("expected Bin(CompressionSize), got {outcome:?}"); - }; - assert_eq!(dataset, "tpch"); - assert_eq!(dataset_variant, Some("10".into())); - assert_eq!(format, "vortex-file-compressed"); -} - -#[test] -fn tpch_compression_size_drops_default_scale_factor() { - // SF "1.0" matches the file-sizes path's filter and collapses to - // dataset_variant: None. - let mut r = record("vortex size/tpch"); - r.dataset = Some(serde_json::json!({ "tpch": { "scale_factor": "1.0" } })); - let outcome = classify_outcome(&r); - let Outcome::Bin(V3Bin::CompressionSize { - dataset_variant, .. - }) = outcome - else { - panic!("expected Bin(CompressionSize), got {outcome:?}"); - }; - assert_eq!(dataset_variant, None); -} - -#[rstest] -// SF=1 is the implicit default; both spellings must drop to None so -// `bin_compression_size` and `migrate_file_sizes` agree. -#[case::int_one("1", None)] -#[case::float_one("1.0", None)] -// SF=10 must produce the same canonical string regardless of spelling. -#[case::int_ten("10", Some("10".into()))] -#[case::float_ten("10.0", Some("10".into()))] -#[case::float_fractional("0.1", Some("0.1".into()))] -#[case::whitespace(" 10 ", Some("10".into()))] -#[case::empty("", None)] -fn compression_size_scale_factor_canonicalizes( - #[case] raw_sf: &str, - #[case] expected: Option, -) { - let mut r = record("vortex size/tpch"); - r.dataset = Some(serde_json::json!({ "tpch": { "scale_factor": raw_sf } })); - let outcome = classify_outcome(&r); - let Outcome::Bin(V3Bin::CompressionSize { - dataset_variant, .. - }) = outcome - else { - panic!("expected Bin(CompressionSize) for sf={raw_sf:?}, got {outcome:?}"); - }; - assert_eq!(dataset_variant, expected, "sf={raw_sf:?}"); -} - -#[test] -fn engine_casing_lowercased() { - // Older v2 records emitted display-case engines like `DataFusion` - // and `DuckDB`. The classifier lowercases at push time so dedup - // collapses display-case rows into the canonical lowercase ones. - let r = record("clickbench_q07/DataFusion:parquet"); - let outcome = classify_outcome(&r); - let Outcome::Bin(V3Bin::Query { engine, format, .. }) = outcome else { - panic!("expected Bin(Query), got {outcome:?}"); - }; - assert_eq!(engine, "datafusion"); - assert_eq!(format, "parquet"); -} - -#[test] -fn rename_engine_pins_canonical_outputs() { - assert_eq!(rename_engine("vortex-tokio-local-disk"), "vortex-nvme"); - assert_eq!( - rename_engine("datafusion:vortex-file-compressed"), - "datafusion:vortex" - ); - assert_eq!(rename_engine("LANCE"), "lance"); -} - -#[test] -fn format_query_pins_v2_display() { - assert_eq!(format_query("clickbench_q00"), "CLICKBENCH Q0"); - assert_eq!(format_query("tpch_q22"), "TPC-H Q22"); - assert_eq!(format_query("tpcds_q42"), "TPC-DS Q42"); - assert_eq!(format_query("polarsignals_q1"), "POLARSIGNALS Q1"); - // Names that don't match a suite fall back to upper + " " replace. - assert_eq!( - format_query("vortex-file-compressed size"), - "VORTEX FILE COMPRESSED SIZE" - ); -} diff --git a/benchmarks-website/migrate/tests/end_to_end.rs b/benchmarks-website/migrate/tests/end_to_end.rs deleted file mode 100644 index 83d71f5f156..00000000000 --- a/benchmarks-website/migrate/tests/end_to_end.rs +++ /dev/null @@ -1,452 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Inline JSONL fixtures driven through the full migration into a -//! tempdir DuckDB. No live S3. - -use std::fs::File; -use std::io::Write; -use std::path::Path; - -use duckdb::Connection; -use flate2::Compression; -use flate2::write::GzEncoder; -use tempfile::TempDir; -use vortex_bench_migrate::migrate; -use vortex_bench_migrate::source::Source; - -const COMMITS_JSONL: &str = r#"{"id":"deadbeef","timestamp":"2026-04-25T00:00:00Z","message":"fixture commit","author":{"name":"A","email":"a@example.com"},"committer":{"name":"C","email":"c@example.com"},"tree_id":"abcd0001","url":"https://example.com/commit/deadbeef"} -"#; - -const DATA_JSONL: &str = r#"{"name":"clickbench_q07/datafusion:parquet","commit_id":"deadbeef","unit":"ns","value":42000,"all_runtimes":[41000,42000,43000]} -{"name":"compress time/clickbench","commit_id":"deadbeef","unit":"ns","value":99} -{"name":"vortex size/clickbench","commit_id":"deadbeef","unit":"bytes","value":1024} -{"name":"random-access/taxi/take/parquet-tokio-local-disk","commit_id":"deadbeef","unit":"ns","value":777,"all_runtimes":[700,777,800]} -"#; - -/// Build a local-source fixture directory. Caller supplies the contents -/// of `commits.json`, `data.json.gz`, and any number of -/// `file-sizes-*.json.gz` files (name → contents). -fn build_fixture(commits: &str, data: &str, file_sizes: &[(&str, &str)]) -> TempDir { - let dir = TempDir::new().expect("tempdir"); - write_text(&dir.path().join("commits.json"), commits); - write_gz(&dir.path().join("data.json.gz"), data); - for (name, body) in file_sizes { - write_gz(&dir.path().join(name), body); - } - dir -} - -fn write_text(path: &Path, body: &str) { - let mut f = File::create(path).unwrap(); - f.write_all(body.as_bytes()).unwrap(); -} - -fn write_gz(path: &Path, body: &str) { - let f = File::create(path).unwrap(); - let mut gz = GzEncoder::new(f, Compression::default()); - gz.write_all(body.as_bytes()).unwrap(); - gz.finish().unwrap(); -} - -#[test] -fn migrate_inline_fixture_populates_each_table() { - let src_dir = build_fixture(COMMITS_JSONL, DATA_JSONL, &[]); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - assert_eq!(summary.records_read, 4, "summary={summary}"); - assert_eq!(summary.uncategorized, 0, "summary={summary}"); - assert_eq!(summary.commits_inserted, 1); - assert_eq!(summary.query_inserted, 1); - assert_eq!(summary.compression_time_inserted, 1); - assert_eq!(summary.compression_size_inserted, 1); - assert_eq!(summary.random_access_inserted, 1); - - let conn = Connection::open(&target).unwrap(); - let count = |table: &str| -> i64 { - conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap() - }; - assert_eq!(count("commits"), 1); - assert_eq!(count("query_measurements"), 1); - assert_eq!(count("compression_times"), 1); - assert_eq!(count("compression_sizes"), 1); - assert_eq!(count("random_access_times"), 1); - - // Spot-check the v3 column values for each kind. - let (engine, format, query_idx, value_ns): (String, String, i32, i64) = conn - .query_row( - "SELECT engine, format, query_idx, value_ns FROM query_measurements", - [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), - ) - .unwrap(); - assert_eq!(engine, "datafusion"); - assert_eq!(format, "parquet"); - assert_eq!(query_idx, 7); - assert_eq!(value_ns, 42000); - - let (dataset, format, op): (String, String, String) = conn - .query_row( - "SELECT dataset, format, op FROM compression_times", - [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), - ) - .unwrap(); - assert_eq!(dataset, "clickbench"); - assert_eq!(format, "vortex-file-compressed"); - assert_eq!(op, "encode"); - - let (dataset, format, value_bytes): (String, String, i64) = conn - .query_row( - "SELECT dataset, format, value_bytes FROM compression_sizes", - [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), - ) - .unwrap(); - assert_eq!(dataset, "clickbench"); - assert_eq!(format, "vortex-file-compressed"); - assert_eq!(value_bytes, 1024); - - let (dataset, format): (String, String) = conn - .query_row("SELECT dataset, format FROM random_access_times", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) - .unwrap(); - assert_eq!(dataset, "taxi/take"); - assert_eq!(format, "parquet"); -} - -#[test] -fn dedup_collision_keeps_one_row() { - // Two data.json.gz lines whose query-measurement dim columns are - // identical (same commit / dataset / engine / format / query_idx, - // and `storage` collapses to "nvme" since `storage` is unset). - // Different `value`s. The accumulator's HashSet - // should drop the second one and bump `summary.deduped`. - const DATA: &str = r#"{"name":"clickbench_q07/datafusion:parquet","commit_id":"deadbeef","unit":"ns","value":111} -{"name":"clickbench_q07/datafusion:parquet","commit_id":"deadbeef","unit":"ns","value":222} -"#; - - let src_dir = build_fixture(COMMITS_JSONL, DATA, &[]); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - assert_eq!(summary.records_read, 2, "summary={summary}"); - assert_eq!(summary.query_inserted, 1, "summary={summary}"); - assert_eq!(summary.deduped, 1, "summary={summary}"); - - let conn = Connection::open(&target).unwrap(); - let n: i64 = conn - .query_row("SELECT COUNT(*) FROM query_measurements", [], |r| r.get(0)) - .unwrap(); - assert_eq!(n, 1); -} - -#[test] -fn dedup_with_conflicting_value_ns_is_counted() { - // Same dim columns, different `value`s. Dedup keeps the first - // and bumps `deduped_with_conflict` because the dropped row's - // value_ns differed from the kept row's. This is the signal we - // care about when watching for silent value-corruption across - // duplicated v2 emissions. - const DATA: &str = r#"{"name":"clickbench_q07/datafusion:parquet","commit_id":"deadbeef","unit":"ns","value":111} -{"name":"clickbench_q07/datafusion:parquet","commit_id":"deadbeef","unit":"ns","value":222} -"#; - - let src_dir = build_fixture(COMMITS_JSONL, DATA, &[]); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - assert_eq!(summary.deduped, 1, "summary={summary}"); - assert_eq!(summary.deduped_with_conflict, 1, "summary={summary}"); -} - -#[test] -fn dedup_with_matching_value_ns_does_not_count_conflict() { - // Same dim columns AND identical `value`s. Dedup still drops the - // duplicate, but `deduped_with_conflict` stays 0. - const DATA: &str = r#"{"name":"clickbench_q07/datafusion:parquet","commit_id":"deadbeef","unit":"ns","value":111} -{"name":"clickbench_q07/datafusion:parquet","commit_id":"deadbeef","unit":"ns","value":111} -"#; - - let src_dir = build_fixture(COMMITS_JSONL, DATA, &[]); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - assert_eq!(summary.deduped, 1, "summary={summary}"); - assert_eq!(summary.deduped_with_conflict, 0, "summary={summary}"); -} - -#[test] -fn compression_size_data_and_file_sizes_merge() { - // A `vortex size/tpch` record from data.json.gz and a - // file-sizes-tpch-nvme.json.gz row covering the same (commit, - // dataset, format, SF) tuple should produce the *same* - // measurement_id so the in-memory accumulator merges them into - // one row instead of two. - // - // Both sources use scale_factor "1.0", which both code paths - // filter out → dataset_variant: None on both sides → matching mid. - const DATA: &str = r#"{"name":"vortex size/tpch","commit_id":"deadbeef","unit":"bytes","value":200,"dataset":{"tpch":{"scale_factor":"1.0"}}} -"#; - const FILE_SIZES: &str = r#"{"commit_id":"deadbeef","benchmark":"tpch","scale_factor":"1.0","format":"vortex-file-compressed","file":"part-0.vortex","size_bytes":100} -"#; - - let src_dir = build_fixture( - COMMITS_JSONL, - DATA, - &[("file-sizes-tpch-nvme.json.gz", FILE_SIZES)], - ); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - assert_eq!(summary.compression_size_inserted, 1, "summary={summary}"); - - let conn = Connection::open(&target).unwrap(); - let (n, value_bytes): (i64, i64) = conn - .query_row( - "SELECT COUNT(*), SUM(value_bytes) FROM compression_sizes", - [], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); - assert_eq!(n, 1); - // data.json.gz seeds value_bytes=200, file-sizes adds 100. - assert_eq!(value_bytes, 300); -} - -#[test] -fn empty_author_email_stored_as_null() { - // v2 sometimes wrote `""` for blank author/email/message. The - // migrator normalizes those to None so DuckDB stores SQL NULL, - // letting the UI distinguish "missing metadata" from "empty - // string". Here author.email is "" — verify the column is NULL, - // not the empty string. - const COMMITS: &str = r#"{"id":"deadbeef","timestamp":"2026-04-25T00:00:00Z","message":"fixture","author":{"name":"A","email":""},"committer":{"name":"C","email":"c@example.com"},"tree_id":"abcd0001","url":"https://example.com/commit/deadbeef"} -"#; - - let src_dir = build_fixture(COMMITS, "", &[]); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - let conn = Connection::open(&target).unwrap(); - let is_null: bool = conn - .query_row( - "SELECT author_email IS NULL FROM commits WHERE commit_sha = 'deadbeef'", - [], - |r| r.get(0), - ) - .unwrap(); - assert!(is_null, "empty author.email must store as SQL NULL"); - - // Non-empty fields still round-trip as strings. - let committer_email: String = conn - .query_row( - "SELECT committer_email FROM commits WHERE commit_sha = 'deadbeef'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(committer_email, "c@example.com"); -} - -#[test] -fn open_target_db_removes_orphan_wal() { - // A `.wal` left from a previous crash with no main file present - // must still be removed so the next run starts from a known-empty - // state. Otherwise DuckDB can replay stale WAL into the fresh DB - // and corrupt subsequent inserts. - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - let wal = target_dir.path().join("v3.duckdb.wal"); - std::fs::write(&wal, b"orphan-wal-bytes").unwrap(); - assert!(wal.exists(), "precondition: orphan wal staged"); - assert!(!target.exists(), "precondition: no main db file"); - - { - let _conn = migrate::open_target_db(&target).unwrap(); - } - - // The migrator opens the DB after sweeping the WAL; DuckDB may - // recreate its own wal under load, but our pre-existing orphan - // bytes must not survive the sweep. We assert by content: either - // the path is missing, or its contents differ from the orphan we - // staged. - if wal.exists() { - let now = std::fs::read(&wal).unwrap(); - assert_ne!( - now, b"orphan-wal-bytes", - "orphan wal bytes must not survive open_target_db" - ); - } -} - -#[test] -fn file_sizes_unknown_id_falls_back_to_unknown_prefix() { - // A file-sizes-*.json.gz whose id isn't in - // `KNOWN_FILE_SIZES_SUITES`, with an empty `benchmark` field, used - // to surface as a bare id like `mystery-suite` and render as a - // dataset name. The migrator now prefixes those with `unknown:` - // so the UI can flag them. - const FILE_SIZES: &str = r#"{"commit_id":"deadbeef","benchmark":"","scale_factor":"","format":"vortex-file-compressed","file":"part-0.vortex","size_bytes":1000} -"#; - - let src_dir = build_fixture( - COMMITS_JSONL, - "", - &[("file-sizes-mystery-suite.json.gz", FILE_SIZES)], - ); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - let conn = Connection::open(&target).unwrap(); - let dataset: String = conn - .query_row("SELECT dataset FROM compression_sizes", [], |r| r.get(0)) - .unwrap(); - assert_eq!(dataset, "unknown:mystery-suite"); -} - -#[test] -fn file_sizes_known_id_uses_id_directly() { - // For a KNOWN_FILE_SIZES_SUITES id, the fallback path keeps the - // raw id (no `unknown:` prefix). `clickbench-nvme` is on the list. - const FILE_SIZES: &str = r#"{"commit_id":"deadbeef","benchmark":"","scale_factor":"","format":"vortex-file-compressed","file":"part-0.vortex","size_bytes":1000} -"#; - - let src_dir = build_fixture( - COMMITS_JSONL, - "", - &[("file-sizes-clickbench-nvme.json.gz", FILE_SIZES)], - ); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - let conn = Connection::open(&target).unwrap(); - let dataset: String = conn - .query_row("SELECT dataset FROM compression_sizes", [], |r| r.get(0)) - .unwrap(); - assert_eq!(dataset, "clickbench-nvme"); -} - -#[test] -fn compression_size_data_and_file_sizes_merge_with_canonical_sf() { - // Same logical SF written as `"10"` on the data.json.gz side and - // `"10.0"` on the file-sizes side. Both paths must canonicalize - // to `"10"` so the rows share a `measurement_id` and merge into - // one compression_sizes row. - const DATA: &str = r#"{"name":"vortex size/tpch","commit_id":"deadbeef","unit":"bytes","value":200,"dataset":{"tpch":{"scale_factor":"10"}}} -"#; - const FILE_SIZES: &str = r#"{"commit_id":"deadbeef","benchmark":"tpch","scale_factor":"10.0","format":"vortex-file-compressed","file":"part-0.vortex","size_bytes":100} -"#; - - let src_dir = build_fixture( - COMMITS_JSONL, - DATA, - &[("file-sizes-tpch-nvme-10.json.gz", FILE_SIZES)], - ); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - assert_eq!(summary.compression_size_inserted, 1, "summary={summary}"); - let conn = Connection::open(&target).unwrap(); - let (n, value_bytes, dataset_variant): (i64, i64, String) = conn - .query_row( - "SELECT COUNT(*), SUM(value_bytes), MAX(dataset_variant) FROM compression_sizes", - [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), - ) - .unwrap(); - assert_eq!(n, 1); - // data.json.gz seeds 200, file-sizes adds 100. - assert_eq!(value_bytes, 300); - assert_eq!(dataset_variant, "10"); -} - -#[test] -fn summary_counts_match_actual_rows_on_success() { - // Sister test to migrate::tests::flush_all_does_not_overcount_on_failure. - // On a fully successful run, the post-flush summary counters must - // equal `SELECT COUNT(*)` from each fact table. This is the - // invariant the flush-after-count refactor preserves. - let src_dir = build_fixture(COMMITS_JSONL, DATA_JSONL, &[]); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - let conn = Connection::open(&target).unwrap(); - let actual = |table: &str| -> u64 { - let n: i64 = conn - .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap(); - n as u64 - }; - assert_eq!(summary.query_inserted, actual("query_measurements")); - assert_eq!( - summary.compression_time_inserted, - actual("compression_times") - ); - assert_eq!( - summary.compression_size_inserted, - actual("compression_sizes") - ); - assert_eq!( - summary.random_access_inserted, - actual("random_access_times") - ); -} - -#[test] -fn file_sizes_sum_into_one_row() { - // Two file-sizes rows sharing (commit, benchmark, format, - // scale_factor) and value_bytes 100 + 200 must collapse to a - // single compression_sizes row with 300. - const FILE_SIZES: &str = r#"{"commit_id":"deadbeef","benchmark":"clickbench","scale_factor":"1.0","format":"vortex-file-compressed","file":"part-0.vortex","size_bytes":100} -{"commit_id":"deadbeef","benchmark":"clickbench","scale_factor":"1.0","format":"vortex-file-compressed","file":"part-1.vortex","size_bytes":200} -"#; - - let src_dir = build_fixture( - COMMITS_JSONL, - "", - &[("file-sizes-clickbench.json.gz", FILE_SIZES)], - ); - let target_dir = TempDir::new().unwrap(); - let target = target_dir.path().join("v3.duckdb"); - - let summary = migrate::run(&Source::Local(src_dir.path().into()), &target).unwrap(); - - assert_eq!(summary.file_size_inserted, 2, "summary={summary}"); - assert_eq!(summary.compression_size_inserted, 1, "summary={summary}"); - - let conn = Connection::open(&target).unwrap(); - let n: i64 = conn - .query_row("SELECT COUNT(*) FROM compression_sizes", [], |r| r.get(0)) - .unwrap(); - assert_eq!(n, 1); - let value_bytes: i64 = conn - .query_row("SELECT value_bytes FROM compression_sizes", [], |r| { - r.get(0) - }) - .unwrap(); - assert_eq!(value_bytes, 300); -} diff --git a/benchmarks-website/ops/BOOTSTRAP.md b/benchmarks-website/ops/BOOTSTRAP.md deleted file mode 100644 index 391f8717980..00000000000 --- a/benchmarks-website/ops/BOOTSTRAP.md +++ /dev/null @@ -1,741 +0,0 @@ - - -# vortex-bench-server bootstrap and recovery walkthrough - -A linear, copy-paste runbook for two scenarios: - -1. **Fresh install**: empty EC2 host, no DB, no S3 backups yet. Phases 1 through 7. -2. **Disaster recovery**: rebuild the site from S3 backups onto a new host (the old host is gone or - its DB is unrecoverable). Phases 1 through 6, then phase 8. - -[`README.md`](README.md) is the topic-organized reference manual; this file is the recipe you follow -top-to-bottom. Every step has a verification command so you can confirm it landed before moving on. -If a verification fails, the troubleshooting note below it points at the most likely cause. - -## Conventions - -- `$` lines are shell commands. Lines without `$` are example output. -- Run everything as `ec2-user` on the EC2 host unless a step says otherwise. `sudo` is called - explicitly where needed. -- The deploy timer cannot fetch over SSH. The repo's `origin` remote MUST be the HTTPS URL - `https://github.com/vortex-data/vortex.git`. If you already cloned over SSH, fix it in place: - `git -C ~/vortex remote set-url origin https://github.com/vortex-data/vortex.git`. -- `/var/lib/vortex-bench/ops/` is a directory symlink that `install.sh` creates pointing at - `/benchmarks-website/ops/`. Every script under it lives in the repo; the symlink is the - source-of-truth pointer. Deleting `~/vortex` breaks all five systemd units atomically. - -## Phase 1: AWS prerequisites (one-time, from the AWS console) - -Skip this entire phase if you are rebuilding into an EC2 instance that already has the -`VortexBenchServerRole` IAM role attached and the bucket lifecycle rule in place. Both survive -instance termination. - -### 1.1 Create the IAM policy - -In **IAM → Policies → Create policy**, paste: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "ListBucket", - "Effect": "Allow", - "Action": "s3:ListBucket", - "Resource": "arn:aws:s3:::vortex-benchmark-results-database" - }, - { - "Sid": "ReadWriteV3Backups", - "Effect": "Allow", - "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], - "Resource": "arn:aws:s3:::vortex-benchmark-results-database/v3-backups/*" - } - ] -} -``` - -Name it `VortexBenchV3Backups`. - -### 1.2 Create the role and attach it - -1. **IAM → Roles → Create role → AWS service → EC2**, attach `VortexBenchV3Backups`, name it - `VortexBenchServerRole`. -2. **EC2 → Instances → bench instance → Actions → Security → Modify IAM role**, pick - `VortexBenchServerRole`, click Update. -3. Wait about 15 seconds for the instance metadata service to refresh. - -### 1.3 Create the S3 lifecycle rule - -**S3 → Buckets → vortex-benchmark-results-database → Management → Lifecycle rules → Create lifecycle -rule**: - -| Field | Value | -| ------------ | ---------------------------------------------- | -| Name | `v3-backups-7d` | -| Status | Enabled | -| Filter scope | Prefix `v3-backups/` | -| Action | Expire current versions, 7 days after creation | - -7 days at one snapshot per hour is 168 tarballs. Tune up or down to taste. - -### 1.4 Verify - -```bash -$ aws sts get-caller-identity -# Arn should end in /VortexBenchServerRole/ - -$ echo probe > /tmp/probe.txt -$ aws s3 cp /tmp/probe.txt s3://vortex-benchmark-results-database/v3-backups/_probe.txt -$ aws s3 ls s3://vortex-benchmark-results-database/v3-backups/ | grep probe -$ aws s3 rm s3://vortex-benchmark-results-database/v3-backups/_probe.txt -$ rm /tmp/probe.txt -``` - -All four operations must succeed. If any fails with `AccessDenied`, check (1) the policy is actually -attached to `VortexBenchServerRole`, (2) the instance is using that role per -`aws sts get-caller-identity`, (3) there is no bucket policy denying access. - -## Phase 2: Host packages (Amazon Linux 2023) - -```bash -$ sudo dnf install -y \ - git curl jq \ - gcc gcc-c++ make cmake pkgconfig \ - util-linux openssl tar gzip -``` - -`util-linux` provides `flock`, which `deploy.sh` uses as a serialization guard. `gcc`, `gcc-c++`, -`cmake`, and `pkgconfig` are required by the `duckdb-sys` build. - -### 2.1 Install the Rust toolchain for `ec2-user` - -```bash -$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -$ source $HOME/.cargo/env -$ rustc --version -``` - -`rustc --version` must succeed. The deploy timer runs `cargo build --release`, which needs this -exact toolchain installed for the user systemd runs the service as (`ec2-user`). - -## Phase 3: Clone the repo - -```bash -$ cd ~ && git clone https://github.com/vortex-data/vortex.git -$ cd vortex -$ git remote -v -origin https://github.com/vortex-data/vortex.git (fetch) -origin https://github.com/vortex-data/vortex.git (push) -``` - -The HTTPS URL is mandatory. If your `git config` defaults rewrite to SSH, undo that for this -checkout: - -```bash -$ git -C ~/vortex remote set-url origin https://github.com/vortex-data/vortex.git -``` - -## Phase 4: Run the installer - -```bash -$ ./benchmarks-website/ops/install.sh -``` - -This is idempotent. It creates `/var/lib/vortex-bench/` owned by `ec2-user` (logs go to -journalctl, not to a file), drops a sudoers fragment at `/etc/sudoers.d/vortex-bench`, copies -`/etc/vortex-bench.env` from the template (mode 0600), symlinks `/var/lib/vortex-bench/ops` to the -repo's `ops/`, and installs the systemd units. - -Expected tail of output: - -``` -[install] install complete. Next steps: -[install] 1. Edit /etc/vortex-bench.env (chmod 0600, owned by ec2-user) -[install] - INGEST_BEARER_TOKEN=... -[install] - ADMIN_BEARER_TOKEN=... -``` - -If the installer warns about an SSH `origin` remote, fix it now (see Phase 3) before starting the -timers. The deploy timer will silently fail every minute otherwise. - -### 4.1 Verify - -```bash -$ ls -ld /var/lib/vortex-bench -drwxr-xr-x. 7 ec2-user ec2-user 4096 ... /var/lib/vortex-bench - -$ sudo ls -l /etc/vortex-bench.env --rw-------. 1 ec2-user ec2-user ... /etc/vortex-bench.env - -$ systemctl list-unit-files 'vortex-bench-*' --no-pager -vortex-bench-backup.service static -vortex-bench-backup.timer enabled -vortex-bench-deploy.service static -vortex-bench-deploy.timer enabled -vortex-bench-server.service enabled -``` - -`enabled` for the two timers and the server unit is the expected state. The deploy and backup -service units are `static` because they have no `[Install]` section and are fired by their -respective timers, not enabled directly. - -## Phase 5: Fill in the env file and start the timers - -### 5.1 Generate the two bearer tokens - -```bash -$ openssl rand -hex 32 # this becomes INGEST_BEARER_TOKEN -$ openssl rand -hex 32 # this becomes ADMIN_BEARER_TOKEN -``` - -Save the `INGEST_BEARER_TOKEN` to the GitHub Actions Environment that the bench CI workflow reads. -The `ADMIN_BEARER_TOKEN` never leaves the box. - -### 5.2 Edit `/etc/vortex-bench.env` - -```bash -$ sudo $EDITOR /etc/vortex-bench.env -``` - -Required fields (defaults are correct for the canonical layout): - -``` -INGEST_BEARER_TOKEN= -ADMIN_BEARER_TOKEN= -REPO_DIR=/home/ec2-user/vortex -DEPLOY_BRANCH=develop -S3_BACKUP_PREFIX=s3://vortex-benchmark-results-database/v3-backups -``` - -The remaining keys (`VORTEX_BENCH_DB`, `VORTEX_BENCH_BIND`, `VORTEX_BENCH_ADMIN_BIND`, `SERVER_URL`, -`ADMIN_URL`, `VORTEX_BENCH_SNAPSHOT_DIR`) already point at the canonical paths. - -### 5.3 Start the timers - -```bash -$ sudo systemctl start vortex-bench-deploy.timer -$ sudo systemctl start vortex-bench-backup.timer -``` - -The server unit starts itself once the deploy timer's first fire produces a binary. Do not -`start vortex-bench-server` directly yet, there is nothing for it to exec. - -### 5.4 Watch the first deploy build the binary - -```bash -$ journalctl -fu vortex-bench-deploy.service -``` - -The first fire takes about 60 to 90 seconds for a cold `cargo build --release`. Every log line is -prefixed with `[deploy ]`. Look for these milestones (paraphrased; the literal substrings -to grep are bolded): - -- **`building <7-char-sha> (was )`** -- cargo build starts. -- **`swapped symlink →`** -- atomic binary swap landed; the next /health probe is imminent. - (The arrow is the literal Unicode `→` deploy.sh emits, not ASCII `->`.) -- **`deploy ok: <7-char-sha> → live (binary )`** -- /health passed and the deploy committed - the stamp. This is the success line. - -`Ctrl-C` out of `journalctl` once the `deploy ok:` line appears. If a deploy fails it will exit -with one of the exit codes documented at the head of [`deploy.sh`](deploy.sh): 1 lock contention, -2 config error, 3 git fetch or checkout, 4 cargo build, 5 systemctl restart (with rollback -re-probed), 6 /health failed but rolled back OK, 7 /health failed AND rollback also broken -- -server is down. The four exit codes a bootstrap operator typically hits (4 through 7) have rows -in [the symptom table](#what-to-do-if-a-step-fails) below; exit codes 1-3 are self-explanatory -in the journal output. - -## Phase 6: Verify the server is up - -### 6.1 Public listener - -```bash -$ curl -fsS http://127.0.0.1:3000/health | jq -{ - "status": "ok", - "build_sha": "abc123...", - "schema_version": "...", - "db_path": "/var/lib/vortex-bench/bench.duckdb", - "row_counts": { - "commits": 0, - "query_measurements": 0, - ... - } -} -``` - -Empty row counts are expected. The DB is created with an empty schema on first server boot. - -### 6.2 Admin listener - -```bash -$ /var/lib/vortex-bench/ops/inspect.sh "SELECT COUNT(*) FROM commits;" -``` - -A `0` is correct (DB is empty). A connection refused means `ADMIN_BEARER_TOKEN` was empty when the -server started: re-check `/etc/vortex-bench.env` and restart the server with -`/var/lib/vortex-bench/ops/restart.sh`. - -### 6.3 Build SHA - -```bash -$ readlink /var/lib/vortex-bench/bin/vortex-bench-server -/var/lib/vortex-bench/bin/vortex-bench-server.. - -$ cat /var/lib/vortex-bench/last-deployed-sha -abc123... -``` - -The SHA in `last-deployed-sha` must match the `build_sha` in the `/health` JSON. - -## Phase 7: Populate the database (fresh install only) - -Pick **one** of 7.A or 7.B. Skip this entire phase if you are rebuilding from a backup (Phase 8 -supplies the data). - -### 7.A Run the v2 to v3 migration - -This is the canonical path for a brand-new install. The migrator reads the v2 source (the public -S3 bucket of v2 result JSONs) and writes into the v3 DuckDB file. - -```bash -$ source /etc/vortex-bench.env -# Check the migrator's own CLI for the up-to-date flag set. The wrapper passes args verbatim to -# `cargo run -p vortex-bench-migrate -- "$@"`, so the v2 source flag lives in that crate: -$ /var/lib/vortex-bench/ops/migrate.sh run --help -# Typical invocation (substitute the v2 source flag the --help output names): -$ /var/lib/vortex-bench/ops/migrate.sh run --output "$VORTEX_BENCH_DB" -``` - -`migrate.sh` stops the server, snapshots the current DB to `bench.prev-.duckdb` for rollback, -runs the migrator, and starts the server back up. The deploy and backup timers are paused for the -duration; they restart automatically on success. - -If the migrator fails, the script leaves the server stopped and the timers paused, and prints the -exact rollback command. Follow it. Do not retry the migration without rolling back first or you will -pile new state on top of partially-migrated state. - -### 7.B Promote an existing DuckDB file - -If you already have a `bench.duckdb` from a previous host or a manual export: - -```bash -$ sudo systemctl stop vortex-bench-server -$ cp /path/to/your/bench.duckdb /var/lib/vortex-bench/bench.duckdb -$ sudo systemctl start vortex-bench-server -$ curl -fsS http://127.0.0.1:3000/health | jq '.row_counts' -``` - -Row counts in `/health` should match the source DB. - -### 7.C Verify the data landed - -```bash -$ /var/lib/vortex-bench/ops/inspect.sh " - SELECT 'commits' AS table_name, COUNT(*) AS n FROM commits - UNION ALL SELECT 'query_measurements', COUNT(*) FROM query_measurements - UNION ALL SELECT 'compression_times', COUNT(*) FROM compression_times - UNION ALL SELECT 'compression_sizes', COUNT(*) FROM compression_sizes - UNION ALL SELECT 'random_access_times', COUNT(*) FROM random_access_times - UNION ALL SELECT 'vector_search_runs', COUNT(*) FROM vector_search_runs; -" -``` - -All six tables should have non-zero row counts that match what you expect from the source. - -### 7.D Verify the backup loop end-to-end - -Fire one snapshot by hand to prove the IAM role, the admin token, and the tarball pipeline all work: - -```bash -$ sudo systemctl start vortex-bench-backup.service -$ journalctl -u vortex-bench-backup.service --since '2 min ago' --no-pager -[backup ...] triggering /api/admin/snapshot?ts=20260520T... -[backup ...] compressing /var/lib/vortex-bench/snapshots/... → ....tar.gz -[backup ...] compressed N → M bytes (Kx) -[backup ...] uploading ....tar.gz → s3://vortex-benchmark-results-database/v3-backups/....tar.gz -[backup ...] deleting local copies ... -[backup ...] snapshot 20260520T... ok → ... - -$ aws s3 ls s3://vortex-benchmark-results-database/v3-backups/ | tail -3 -``` - -The tarball must appear in the listing. If `aws s3 cp` fails with `AccessDenied`, redo Phase 1.4 to -debug the IAM role. - -**At this point the system is fully self-driving.** Deploys land within 60 seconds of a develop -merge, snapshots upload every hour, the lifecycle rule expires old ones. You do not need to SSH back -in for routine operations. - -## Phase 8: Disaster recovery: restore the DB from an S3 backup - -Use this phase to rebuild onto a fresh host when the old host or DB is unrecoverable. **Do not run -it on a healthy host with a populated DB**, it overwrites the live `bench.duckdb`. - -You must have completed Phases 1 through 6 first (the server runs against an empty schema). Skip -Phase 7. - -### 8.1 Pick the snapshot you want to restore - -```bash -$ aws s3 ls s3://vortex-benchmark-results-database/v3-backups/ | tail -20 -2026-05-20 01:00:14 12345678 20260520T010000Z.tar.gz -2026-05-20 02:00:11 12345678 20260520T020000Z.tar.gz -... -``` - -The lifecycle rule keeps about 168 hourly snapshots (7 days). Pick the most recent known-good one, -or `tail -1` to grab the latest. - -### 8.2 Download and extract - -```bash -$ ts=20260520T020000Z # replace with the timestamp from 8.1 -$ cd /tmp -$ aws s3 cp "s3://vortex-benchmark-results-database/v3-backups/${ts}.tar.gz" . -$ tar xzf "${ts}.tar.gz" -$ ls /tmp/${ts}/ -schema.sql -commits.vortex -query_measurements.vortex -compression_times.vortex -compression_sizes.vortex -random_access_times.vortex -vector_search_runs.vortex -``` - -Six `.vortex` files plus `schema.sql`. If any file is missing, the snapshot is incomplete, pick an -earlier one. - -### 8.3 Stop the server and clear the empty DB - -```bash -$ sudo systemctl stop vortex-bench-server -$ rm -f \ - /var/lib/vortex-bench/bench.duckdb \ - /var/lib/vortex-bench/bench.duckdb.wal -``` - -(`bench.duckdb` is owned by `ec2-user` per the install layout; deleting it does not need sudo.) - -### 8.4 Install the duckdb CLI matching the bundled engine - -```bash -$ duckdb --version -v1.5.x ... -``` - -The CLI version must be at least as new as the engine the server bundles (currently `1.5.x`). If -`duckdb` is missing or older: - -```bash -$ curl -L https://github.com/duckdb/duckdb/releases/latest/download/duckdb_cli-linux-amd64.zip -o /tmp/duckdb.zip -$ unzip -j /tmp/duckdb.zip duckdb -d ~/bin -$ export PATH="$HOME/bin:$PATH" -$ duckdb --version -``` - -### 8.5 Rehydrate the DB from the snapshot - -The block below uses `${ts}` from Phase 8.2; the guard re-derives it from `/tmp/` if a fresh shell -lost the variable, so this step is safe to copy-paste into a new terminal. - -```bash -$ : "${ts:?ts is not set; \`ts=\` or re-source /etc/vortex-bench.env}" -$ [ -d "/tmp/${ts}" ] || { echo "missing /tmp/${ts}; redo 8.2" >&2; exit 1; } -$ duckdb /var/lib/vortex-bench/bench.duckdb <.duckdb`, runs the -migrator, and starts the server back up. The autopilot timers are paused for the duration and -restart on success. On failure they intentionally stay stopped (see Phase 9 for the rollback -recipe). - -```bash -$ source /etc/vortex-bench.env -$ /var/lib/vortex-bench/ops/migrate.sh run --output "${VORTEX_BENCH_DB:-/var/lib/vortex-bench/bench.duckdb}" -``` - -`migrate.sh`'s positional args pass straight through to `cargo run -p vortex-bench-migrate --`, so -the migrator's CLI is whatever the current branch says it is. As of writing it is -`run --output `. - -### Token rotation - -`INGEST_BEARER_TOKEN`: - -```bash -$ openssl rand -hex 32 # generate new value -# 1. Update the GitHub Actions Environment secret so CI uses the new value. -# 2. Edit /etc/vortex-bench.env with the new value. -$ sudo $EDITOR /etc/vortex-bench.env -$ /var/lib/vortex-bench/ops/restart.sh # picks up the new env -``` - -`ADMIN_BEARER_TOKEN`: - -```bash -$ openssl rand -hex 32 # generate new value -$ sudo $EDITOR /etc/vortex-bench.env -$ /var/lib/vortex-bench/ops/restart.sh -# The next backup timer fire reads the env file fresh, so it picks up -# the new value automatically. -``` - -The two tokens are independent. Rotating one does not affect the other. - -### Adding or removing an admin - -Being an admin is three independent grants, not a single role: - -```bash -# (1) SSH access to the EC2 box. -# Append the new admin's public key to authorized_keys. They log in -# as ec2-user (which is also the service identity). -$ sudo -u ec2-user $EDITOR /home/ec2-user/.ssh/authorized_keys -# Or use AWS SSM Session Manager: enable on the instance and add the -# admin's IAM principal to the instance's SSM connect IAM policy. - -# (2) AWS console access for IAM/lifecycle/bucket-policy changes -# (the runtime role intentionally cannot do these). -# Grant via IAM user or SSO role with read/write on IAM and the -# vortex-benchmark-results-database bucket. - -# (3) Bearer-token knowledge, if they need to hit /api/admin/* from -# their laptop. /etc/vortex-bench.env is mode 0600 owned by ec2-user; -# anyone with SSH access can read it. -``` - -To revoke an admin: delete their key from `authorized_keys`, revoke their AWS console role, and -rotate `ADMIN_BEARER_TOKEN`. CI's `INGEST_BEARER_TOKEN` is unaffected because it lives in GitHub -Actions, not on the host. - -### Disk pressure - -`/var/lib/vortex-bench/` filling up has four typical causes (see -`du -sh /var/lib/vortex-bench/* | sort -h` to identify which): - -```bash -# `bin/vortex-bench-server..` accumulation - deploy.sh keeps the -# last $KEEP_BINARIES (default 3). To prune harder: -$ sudo $EDITOR /etc/vortex-bench.env # add KEEP_BINARIES=1 -$ /var/lib/vortex-bench/ops/force-rebuild.sh # next deploy enforces the new cap - -# `snapshots//` not deleted - backup.sh removes after a successful -# S3 upload, so leftover dirs imply the upload failed. Check: -$ journalctl -u vortex-bench-backup.service --since '4 hours ago' - -# `bench.prev-.duckdb` accumulation from old migrations. These are -# kept on purpose for rollback. Delete by hand once verified: -$ ls -lt /var/lib/vortex-bench/bench.prev-*.duckdb -$ rm /var/lib/vortex-bench/bench.prev-.duckdb{,.wal} - -# `bench.duckdb` itself growing - expected, hundreds of MB is normal. -``` - -## What to do if a step fails - -| Symptom | Likely cause | Fix | -| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -| `install.sh` exits with `ERROR: not found. Set REPO_DIR=.` | Running from outside the repo root or with a non-default `REPO_DIR` | `cd ~/vortex && ./benchmarks-website/ops/install.sh` | -| `journalctl -u vortex-bench-deploy` shows `Permission denied (publickey)` | `origin` is the SSH remote | `git -C ~/vortex remote set-url origin https://github.com/vortex-data/vortex.git` | -| `journalctl -u vortex-bench-deploy` shows `cargo: command not found` | Rust toolchain not installed for `ec2-user` | Re-run Phase 2.1; the timer runs as `ec2-user`, not as you | -| First `curl /health` returns connection refused | Deploy timer has not produced a binary yet, or the build failed | `journalctl -fu vortex-bench-deploy.service` and read the most recent failure | -| `inspect.sh` returns 401 or 503 | `ADMIN_BEARER_TOKEN` was empty at server start, the admin listener never bound | Edit `/etc/vortex-bench.env`, `restart.sh` | -| `backup.sh` logs `/api/admin/snapshot returned 000` | The server is not running, or the admin port is wrong | `systemctl status vortex-bench-server`, check `$ADMIN_URL` in the env file | -| `backup.sh` logs `aws s3 cp failed` | IAM role missing or wrong | Re-run Phase 1.4 to debug | -| `migrate.sh` exits with the rollback instructions | The migrator itself errored, the prev DB is intact | Follow the printed `mv` lines literally | -| Phase 8.5 `INSTALL vortex` fails | DuckDB CLI is older than the bundled engine | Upgrade the CLI per Phase 8.4 | -| `deploy.sh` exits 4 (`cargo build failed`) | Source-tree compile error | Read the build log in `journalctl -u vortex-bench-deploy.service`; fix and push | -| `deploy.sh` exits 5 (`systemctl restart failed`) | systemd or sudoers issue | `systemctl status vortex-bench-server`; check the sudoers fragment at `/etc/sudoers.d/vortex-bench` | -| `deploy.sh` exits 6 (`/health failed, rolled back to prior binary`) | New binary broken; prior binary healthy | Fix the source and push the next commit; the live binary is the prior good one | -| `deploy.sh` exits 7 (`/health failed AND rollback also broken`) | Server is DOWN; both new and prior binaries fail /health | Pick a known-good binary from `/var/lib/vortex-bench/bin/`, `sudo ln -snT /var/lib/vortex-bench/bin/vortex-bench-server`, `sudo systemctl restart vortex-bench-server` | - -See [`README.md`](README.md#failure-modes-conceptual) "Failure modes (conceptual)" for the full -reference list. This file covers only the failure modes a bootstrap operator actually hits. diff --git a/benchmarks-website/ops/README.md b/benchmarks-website/ops/README.md deleted file mode 100644 index 80c7651b051..00000000000 --- a/benchmarks-website/ops/README.md +++ /dev/null @@ -1,323 +0,0 @@ - - -# vortex-bench-server - operations runbook - -This is the canonical guide for deploying and operating the v3 benchmarks site (`bench.vortex.dev`) -on EC2. It targets a fresh admin who has SSH access to the box and never seen the system before. - -The contents of this directory are everything the EC2 host needs to build, run, deploy, back up, and -inspect the server. There is no out-of-tree state - every script and unit lives in -`benchmarks-website/ops/` and gets installed onto the host by [`install.sh`](install.sh). - -## TL;DR - -- One Rust binary (`vortex-bench-server`), one DuckDB file (`/var/lib/vortex-bench/bench.duckdb`). -- A systemd timer polls `origin/develop` every 60s. If commits in the range touch website-relevant - paths it builds, atomically swaps the binary, and restarts the server. Otherwise it fast-forwards - the working tree and exits. -- A second timer fires hourly, asks the server to write a per-table Vortex snapshot (`schema.sql` + - one `.vortex` per table), `tar czf`s it, and uploads to - `s3://vortex-benchmark-results-database/v3-backups/.tar.gz`. The vortex DuckDB extension - is auto-installed from the DuckDB core extension repo on first call. Vortex compresses the - BIGINT[] runtime arrays and string columns roughly an order of magnitude better than gzipped CSV - - and dogfoods the project's own format. -- For ad-hoc reads, `inspect.sh` calls a bearer-gated `/api/admin/sql` endpoint instead of stopping - the server. -- For DB-replacing operations (re-running the v2→v3 migration), `migrate.sh` stops the server, - snapshots the current DB to `bench.prev-.duckdb`, runs the migration, and starts back up. - -## Architecture - -``` -┌──────────────────────────────────────────────────────────────────────┐ -│ EC2 host (Amazon Linux 2023, ec2-user) │ -│ │ -│ /home/ec2-user/vortex/ ← git checkout (build context only) │ -│ │ -│ /var/lib/vortex-bench/ │ -│ bench.duckdb ← live DB │ -│ bench.duckdb.wal │ -│ bench.prev-.duckdb ← pre-migration backup, never pruned │ -│ bin/ │ -│ vortex-bench-server ← symlink → versioned binary │ -│ vortex-bench-server.. │ -│ ← versioned (PID suffix breaks │ -│ same-second collisions), last │ -│ $KEEP_BINARIES (3) │ -│ snapshots// ← transient vortex-snapshot landing │ -│ last-deployed-sha ← stamp file for the deploy timer │ -│ .deploy.lock ← flock guard │ -│ ops -> /home/ec2-user/vortex/benchmarks-website/ops │ -│ │ -│ /etc/vortex-bench.env ← secrets, mode 0600 │ -│ /etc/sudoers.d/vortex-bench ← lets ec2-user systemctl restart │ -│ the server with no password │ -│ /etc/systemd/system/ │ -│ vortex-bench-server.service ← serves :3000 │ -│ vortex-bench-deploy.service ← oneshot, runs deploy.sh │ -│ vortex-bench-deploy.timer ← every 60s │ -│ vortex-bench-backup.service ← oneshot, runs backup.sh │ -│ vortex-bench-backup.timer ← hourly │ -│ │ -│ Logs: journalctl -u vortex-bench-{server,deploy,backup} │ -└──────────────────────────────────────────────────────────────────────┘ - │ - │ aws s3 cp .tar.gz - ▼ - ┌───────────────────────────────────────┐ - │ s3://vortex-benchmark-results-database/│ - │ v3-backups/ │ - │ .tar.gz │ - │ / │ - │ schema.sql │ - │
.vortex │ - └───────────────────────────────────────┘ -``` - -## Files in this directory - -| Path | Role | -| -------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| [`install.sh`](install.sh) | One-time bootstrap on a fresh host. Idempotent. | -| [`deploy.sh`](deploy.sh) | Pull → build (if needed) → atomic restart. Called by timer. | -| [`migrate.sh`](migrate.sh) | Manual: stop, snapshot prev DB, run migrate, restart. | -| [`backup.sh`](backup.sh) | Hourly: trigger `/api/admin/snapshot`, sync to S3, prune local. | -| [`inspect.sh`](inspect.sh) | Read-only SQL via `/api/admin/sql`, no server stop. | -| [`force-rebuild.sh`](force-rebuild.sh) | Re-run a deploy of `$DEPLOY_BRANCH` even when origin hasn't moved. | -| [`restart.sh`](restart.sh) | Restart the binary in place with visible before/after state. | -| [`config/vortex-bench.env.example`](config/vortex-bench.env.example) | Template for `/etc/vortex-bench.env`. | -| [`systemd/`](systemd/) | Unit files installed into `/etc/systemd/system/`. | -| [`BOOTSTRAP.md`](BOOTSTRAP.md) | Step-by-step bootstrap and recovery runbook (copy-paste, verify-as-you-go). | - -**Every runnable command lives in [`BOOTSTRAP.md`](BOOTSTRAP.md).** This file explains *what* the -system is and *why* the moving parts are shaped the way they are. Operators run commands out of -`BOOTSTRAP.md`; the sections below are the conceptual companion you read before or after. - -## How the system runs - -### The deploy autopilot - -`vortex-bench-deploy.timer` fires every 60s. The service it triggers fetches `origin/$DEPLOY_BRANCH`, -compares the tip SHA against `/var/lib/vortex-bench/last-deployed-sha`, and exits early if nothing -moved. When the SHA has moved, the script inspects the diff against the old SHA: it only rebuilds -when the change touches `benchmarks-website/server/`, `benchmarks-website/migrate/`, -`benchmarks-website/Cargo.toml`, the workspace `Cargo.toml`, or `Cargo.lock`. Everything else (e.g. a -vortex-array PR) fast-forwards the working tree so the next website change builds against fresh -dependencies, but the running binary is left alone. - -When a rebuild is needed: `cargo build --release` produces a versioned binary at -`bin/vortex-bench-server..`, the symlink at `bin/vortex-bench-server` swings to it -atomically, the server unit restarts, and `deploy.sh` probes `/health` for up to 30s. On any failure -(build, restart, health check) the symlink rolls back to the previous binary and the server restarts -on the old version. The stamp file is **not** written on a failed deploy, so the next timer fire -retries the same SHA. Fix the bug and push again. - -The flock at `/var/lib/vortex-bench/.deploy.lock` serializes deploy / force-rebuild / manual-deploy -attempts. The `force-rebuild.sh` sentinel (`.force-rebuild` under `STATE_DIR`) bypasses the path -filter and the stamp comparison once, then deletes itself. - -### Identifying the running build - -Three identifiers should always agree on a healthy host: - -| Source | What it represents | -| -------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `cat /var/lib/vortex-bench/last-deployed-sha` | What the deploy timer last successfully rolled out. | -| `readlink /var/lib/vortex-bench/bin/vortex-bench-server` | Which versioned binary the symlink points at (its filename embeds the build timestamp). | -| `curl /health` → `.build_sha` | What `cargo build` saw at compile time, baked into the running process. | - -`build_sha` is the source of truth. Disagreement means the running process is stale: a manual binary -swap, a `restart.sh` with no rebuild, or systemd holding an older pid open. - -### The three "restart" semantics - -Pick the smallest hammer: - -- **`restart.sh`** restarts the existing binary. Cheapest. Useful after editing - `/etc/vortex-bench.env` or recovering from a stuck connection. `build_sha` does not change; `pid` - and `started` do. -- **Triggering the deploy service** runs the timer's normal flow now instead of waiting up to 60s. - No-op if `origin/$DEPLOY_BRANCH` has not moved. -- **`force-rebuild.sh`** ignores both the stamp file and the path filter, so it rebuilds whatever's - on `$DEPLOY_BRANCH` even when origin has not moved. Use this when you flipped `DEPLOY_BRANCH`, are - recovering from wedged build artifacts, or want to redeploy "whatever is on the branch I'm - tracking." - -There is no "build whatever I have locally checked out" mode. The deploy script always builds -origin's tip, so to test a branch you push it first. - -### Migration semantics - -The v2 to v3 migration is destructive: it overwrites `/var/lib/vortex-bench/bench.duckdb` from the v2 -source. `migrate.sh` treats this as the most dangerous operation in the system: - -1. Pause both autopilot timers AND interrupt any in-flight service so a deploy or backup cannot race - the migrator's exclusive DB access. -2. Stop the server. -3. Copy the current `bench.duckdb` (and any `.wal`) to `bench.prev-.duckdb` for instant - rollback. -4. Run the migrator (positional args pass straight through to the `vortex-bench-migrate` CLI). -5. Start the server, poll `/health` for up to 30s. -6. On success, restart the autopilot timers. On **failure**, intentionally leave the autopilot - timers stopped and print the rollback command. The prev DB is never auto-deleted; the operator - removes it once they've verified the migration. - -This means "deploy the latest commit" and "rerun the migration" are deliberately distinct actions. -The autopilot never touches the DB. - -### The backup loop - -`vortex-bench-backup.timer` fires hourly and `vortex-bench-backup.service` runs `backup.sh`. The -script POSTs to the loopback-only `/api/admin/snapshot` endpoint, which writes a per-table Vortex -snapshot (`schema.sql` plus one `
.vortex` per table) into -`$VORTEX_BENCH_SNAPSHOT_DIR//`. The script then tars and gzips that directory, uploads to -`$S3_BACKUP_PREFIX/.tar.gz`, and deletes the local copies. The bucket lifecycle rule expires old -objects (default 7 days, 168 hourly snapshots). - -Vortex compresses our shape (BIGINT[] runtime arrays + short strings) about an order of magnitude -better than gzipped CSV, and dogfoods the project's own format. The gzip on the tarball mostly -catches `schema.sql` and tar metadata, not the data columns themselves. - -`/api/admin/snapshot` requires `ts` to match `[A-Za-z0-9_-]{1,64}` and refuses to overwrite an -existing snapshot directory (409). The same endpoint is used out-of-band before risky operations; -just pick a label the timer can't collide with (e.g. `manual-`). - -### Why two listeners? - -The server binds two ports: a public listener (`VORTEX_BENCH_BIND`, typically `0.0.0.0:3000`) carries -`/`, `/api/ingest`, and `/health`. A separate admin listener (`VORTEX_BENCH_ADMIN_BIND`, mandatorily -loopback-only, `127.0.0.1:3001`) carries `/api/admin/*`. The admin listener fails to start on a -non-loopback bind, so `/api/admin/*` cannot reach the public network even when the public bind opens -`0.0.0.0`. Hitting `/api/admin/*` on the public listener always 404s. - -The admin router is mounted only when `ADMIN_BEARER_TOKEN` is set. With the env unset (e.g. local -dev) no admin listener is bound at all, and `backup.sh` / `inspect.sh` fail fast. There is no silent -"backups disabled" mode. - -### The "three grants" admin model - -There is no admin database. Being an admin is three independent things: - -1. **SSH access to the EC2 box** as `ec2-user` (the same identity systemd runs the service as). - Granted by adding the admin's public key to `authorized_keys`, or via AWS Systems Manager Session - Manager. -2. **AWS console access** for the metadata the runtime role intentionally cannot reach (IAM, bucket - policy, lifecycle rules). Granted via IAM user or SSO role. -3. **Bearer-token knowledge** for hitting `/api/admin/*` directly. Anyone with SSH access can read - `/etc/vortex-bench.env`, so this grant follows from grant 1. - -Revoking an admin therefore means revoking all three: drop the SSH key, revoke the AWS role, rotate -`ADMIN_BEARER_TOKEN`. CI's `INGEST_BEARER_TOKEN` is unaffected because it lives in GitHub Actions, -not on the host. - -## State on disk - -Every persistent file the system owns lives under `/var/lib/vortex-bench/` (state) or `/etc/` -(config): - -| Path | Owner | Lifetime | -| ----------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------- | -| `/var/lib/vortex-bench/bench.duckdb` (+ `.wal`) | server | Live data; replaced by migrate, restored from S3. | -| `/var/lib/vortex-bench/bench.prev-.duckdb` | `migrate.sh` | Kept until operator deletes; rollback target. | -| `/var/lib/vortex-bench/bin/vortex-bench-server` | `deploy.sh` | Symlink to current versioned binary. | -| `/var/lib/vortex-bench/bin/vortex-bench-server..` | `deploy.sh` | Versioned binaries; last `$KEEP_BINARIES` (default 3) kept. | -| `/var/lib/vortex-bench/snapshots//` | `/api/admin/snapshot` | Transient; `backup.sh` deletes after S3 upload. | -| `/var/lib/vortex-bench/last-deployed-sha` | `deploy.sh` | Stamp file; only written on success. | -| `/var/lib/vortex-bench/.deploy.lock` | `deploy.sh` | flock serialization guard. | -| `/var/lib/vortex-bench/duckdb-extensions/` | DuckDB | Writable extension install dir (`ProtectHome` blocks the default DuckDB path). | -| `/var/lib/vortex-bench/ops` | `install.sh` | Symlink to `/benchmarks-website/ops/`. | -| `/etc/vortex-bench.env` | `install.sh` then operator | Mode 0600 owned by ec2-user; both server and timers read it. | -| `/etc/sudoers.d/vortex-bench` | `install.sh` | Grants the run user `systemctl restart`/`start`/`stop` on the v3 units only. | -| `/etc/systemd/system/vortex-bench-*.{service,timer}` | `install.sh` | The five units. | - -## First-time install and disaster recovery - -Removed from this file. Both flows are now in [`BOOTSTRAP.md`](BOOTSTRAP.md): Phases 1 through 7 for -a fresh install, Phases 1 through 6 then 8 for a backup-restore rebuild, Phase 9 for rolling back a -botched migration. Each phase has a verification command so you find out immediately if a step did -not land. Edit `BOOTSTRAP.md` (not this file) when the procedure changes. - -## Wire APIs the ops scripts depend on - -These are the only server endpoints the operator scripts touch. They also constitute the public -admin contract for any future tooling. - -The server exposes two listeners. The public listener carries everything operator-facing and -CI-facing; the admin listener stays loopback-only so `/api/admin/*` cannot reach the public network -even when the public bind opens `0.0.0.0`. - -| Method + path | Bearer | Listener (env var) | Used by | -| ---------------------------------------------------------------- | ------ | ----------------------------------------------- | ------------------------------ | -| `GET /health` | none | public (`$SERVER_URL`, `VORTEX_BENCH_BIND`) | `deploy.sh` post-restart probe | -| `POST /api/ingest` | ingest | public | CI dual-write | -| `POST /api/admin/snapshot?ts=` | admin | admin (`$ADMIN_URL`, `VORTEX_BENCH_ADMIN_BIND`) | `backup.sh` | -| `POST /api/admin/sql` (body `{"sql": …}`, `?format=json\|table`) | admin | admin | `inspect.sh` | - -`POST /api/admin/snapshot` writes `schema.sql` + per-table `.vortex` files; `ts` must match -`[A-Za-z0-9_-]{1,64}` and the directory must not exist (409 otherwise). `POST /api/admin/sql` allows -only `SELECT`/`WITH`/`PRAGMA`/`SHOW`/`DESCRIBE`/`EXPLAIN` and runs each statement inside -`BEGIN TRANSACTION READ ONLY`. - -The admin router is mounted only when `ADMIN_BEARER_TOKEN` is set. With the env unset (e.g. in local -dev) no admin listener is bound at all - `backup.sh` and `inspect.sh` fail fast against -`$ADMIN_URL`, so there's no silent "backups disabled" mode. Hitting `/api/admin/*` on the **public** -listener always 404s, regardless of whether admin is configured. - -See [`server/src/admin.rs`](../server/src/admin.rs) for the full contract and the validation rules. - -## Failure modes (conceptual) - -When something breaks, the symptom usually points at exactly one of these. For the actual diagnostic -and repair commands, see the symptom table in -[`BOOTSTRAP.md`](BOOTSTRAP.md#what-to-do-if-a-step-fails). - -- **Deploys retry the same broken SHA forever.** The stamp file is only written on success, so a - failing deploy attempts the same SHA on every 60s tick. Fix the bug and push, or pause the timer - while you investigate. -- **`/health` is slow.** It runs six `SELECT COUNT(*)`s under the connection mutex. Over 1s during a - benchmark ingest window is normal; over 30s means the mutex is stuck. -- **Disk filling under `/var/lib/vortex-bench/`.** Four culprits in order of likelihood: piled-up - `bench.prev-*.duckdb` from old migrations, leftover `snapshots//` directories (backup uploads - failing), accumulated versioned binaries (`KEEP_BINARIES` too high), the live `bench.duckdb` itself - (expected to grow over time). -- **Backups not running.** Either the timer is stopped, the IAM role is broken, or the server - started without `ADMIN_BEARER_TOKEN` so the admin listener never bound and `curl` to it returns - `000`. -- **Migrate failed partway.** `migrate.sh` leaves the server and the autopilot timers stopped on - failure and prints the rollback commands on stderr. The prev DB is on local disk and complete; - restore it before doing anything else. -- **Migrating to a new EC2 host.** Stand the new host up, take a final snapshot on the old host, - restore from S3 on the new host, cut DNS. Total RPO is bounded by the backup timer interval (one - hour by default). - -## Local development - -You don't need any of this to run the server locally: - -```bash -INGEST_BEARER_TOKEN=dev \ -ADMIN_BEARER_TOKEN=dev \ -VORTEX_BENCH_DB=/tmp/bench.duckdb \ -cargo run -p vortex-bench-server -``` - -The admin endpoints work the same as in production. The hourly timer and the deploy timer are -systemd-only - they have no local equivalent and don't need one. - -## What's intentionally not here - -- **Docker.** A previous iteration ran the server under `docker compose` with `watchtower` polling - GHCR. We removed it: the binary is small enough that a build-on-host model is simpler, and systemd - gives us atomic restarts and rollback for free. The v2 React site retains its image-based deploy - (separate `Dockerfile` and CI workflow); v3 does not. -- **A push-based deploy.** A GitHub Actions workflow could push via SSM or SSH on every merge. We - chose polling because (a) zero inbound surface on the EC2 box, (b) no shared secret to manage in - CI, and (c) 60s is well under any reasonable expectation for a benchmarks site. If the polling - becomes unworkable, swap `vortex-bench-deploy.timer` for an SSM-triggered ExecStart and the rest - of `deploy.sh` doesn't change. -- **A dedicated SQL endpoint user.** `/api/admin/sql` is gated by the same admin token as - `/api/admin/snapshot`. If you want per-operator audit, run a reverse proxy that adds a header and - log it on the way through. diff --git a/benchmarks-website/ops/backup.sh b/benchmarks-website/ops/backup.sh deleted file mode 100755 index dd26372f8d0..00000000000 --- a/benchmarks-website/ops/backup.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Hourly snapshot to S3, called by vortex-bench-backup.timer. -# -# Asks the running server to write a per-table Vortex snapshot via -# /api/admin/snapshot (so the writer uses the same DuckDB process -# that owns the file - no stop required), `tar czf`s the resulting -# directory into a single archive, uploads it to -# $S3_BACKUP_PREFIX/.tar.gz, and deletes the local copies. -# -# Vortex compresses our shape (mostly BIGINT[] runtime arrays + short -# strings) far better than gzipped CSV; the additional gzip on the -# tarball is largely catching schema.sql and tar metadata, not the -# data files themselves. -# -# The instance IAM role must already permit s3:PutObject under -# $S3_BACKUP_PREFIX. The v3 bucket is vortex-benchmark-results-database -# (distinct from v2's vortex-ci-benchmark-results). - -set -euo pipefail - -ENV_FILE="${ENV_FILE:-/etc/vortex-bench.env}" -if [ ! -f "$ENV_FILE" ]; then - echo "ERROR: missing ${ENV_FILE}" >&2 - exit 2 -fi -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a -: "${ADMIN_BEARER_TOKEN:?ADMIN_BEARER_TOKEN must be set in ${ENV_FILE}}" -: "${VORTEX_BENCH_SNAPSHOT_DIR:?VORTEX_BENCH_SNAPSHOT_DIR must be set}" -: "${S3_BACKUP_PREFIX:?S3_BACKUP_PREFIX must be set in ${ENV_FILE}}" -# `ADMIN_URL` points at the loopback-only admin listener; `SERVER_URL` -# stays for /health checks on the public listener. -: "${ADMIN_URL:=http://127.0.0.1:3001}" -: "${STATE_DIR:=/var/lib/vortex-bench}" -: "${BACKUP_LOCK_FILE:=${STATE_DIR}/.backup.lock}" - -log() { printf '[backup %s] %s\n' "$(date -u +%H:%M:%SZ)" "$*"; } - -# Serialise against ourselves: a manual `bash backup.sh` racing the timer -# fires would otherwise both hit /api/admin/snapshot at the same ts (the -# server returns 409 to the loser), then both race on `rm -rf "$local_dir"` -# while the survivor is mid-tar. Quiet bail on contention so the timer -# journal stays clean. -mkdir -p "$(dirname "$BACKUP_LOCK_FILE")" -exec 200>"$BACKUP_LOCK_FILE" -if ! flock -n 200; then - log "another backup is in progress; bailing" - exit 0 -fi - -ts="$(date -u +%Y%m%dT%H%M%SZ)" -local_dir="${VORTEX_BENCH_SNAPSHOT_DIR}/${ts}" -archive="${VORTEX_BENCH_SNAPSHOT_DIR}/${ts}.tar.gz" -remote="${S3_BACKUP_PREFIX}/${ts}.tar.gz" - -# Per-PID scratch files so a manual `bash backup.sh` invocation running -# alongside the timer-driven invocation does not clobber each other's -# response capture or the curl auth header. Cleaned up on exit/trap. -scratch="$(mktemp -d "${TMPDIR:-/tmp}/vortex-bench-backup.XXXXXX")" -trap 'rm -rf "$scratch"' EXIT -response="${scratch}/snapshot.out" -auth_header="${scratch}/auth.hdr" - -# Write the Authorization header to a 0600 file and pass via `-H @path` -# so the bearer token never appears in argv (visible to anyone reading -# `ps aux`). Same pattern in inspect.sh. Wrap in `set +x; ...; set -x` -# guard so an operator running `bash -x backup.sh` does not see the -# bearer in the trace output. -umask 077 -{ _xtrace="$(set +o | grep xtrace)"; set +x; } 2>/dev/null -printf 'Authorization: Bearer %s\n' "${ADMIN_BEARER_TOKEN}" > "$auth_header" -eval "$_xtrace" 2>/dev/null || true - -log "triggering /api/admin/snapshot?ts=${ts}" -http_status=$(curl -sS -o "$response" -w '%{http_code}' \ - -X POST \ - -H "@${auth_header}" \ - "${ADMIN_URL}/api/admin/snapshot?ts=${ts}" || echo "000") -if [ "$http_status" != "200" ]; then - echo "ERROR: /api/admin/snapshot returned ${http_status}" >&2 - cat "$response" >&2 || true - exit 3 -fi - -if [ ! -d "$local_dir" ]; then - echo "ERROR: server reported success but ${local_dir} does not exist" >&2 - exit 4 -fi - -# Completeness check: the server writes schema.sql plus one .vortex file -# per fact + dim table. If a deploy-timer restart interrupted the snapshot -# write mid-stream, the directory may be partially populated; the only -# completeness signal otherwise would be the presence of the dir, which -# tar+s3 cp would happily pack and upload as a "valid" archive that -# fails restore (`INSERT INTO ... silently no-op'd` per BOOTSTRAP 8.5). -required_files=( - schema.sql - commits.vortex - query_measurements.vortex - compression_times.vortex - compression_sizes.vortex - random_access_times.vortex - vector_search_runs.vortex -) -missing=() -for f in "${required_files[@]}"; do - [ -e "${local_dir}/${f}" ] || missing+=("$f") -done -if [ "${#missing[@]}" -gt 0 ]; then - echo "ERROR: snapshot ${local_dir} is incomplete; missing: ${missing[*]}" >&2 - echo " Most common cause: vortex-bench-server was restarted mid-snapshot." >&2 - echo " Leaving the partial directory in place for inspection." >&2 - exit 4 -fi - -# Compress the snapshot directory into a single tar.gz. `tar -C` so paths -# inside the archive are relative to the snapshot id (i.e. `/schema.sql` -# and `/
.vortex`), which matches the layout expected by the -# restore docs. -log "compressing ${local_dir} → ${archive}" -if ! tar -C "$VORTEX_BENCH_SNAPSHOT_DIR" -czf "$archive" "$ts"; then - echo "ERROR: tar czf failed" >&2 - rm -f "$archive" - exit 5 -fi - -orig_bytes=$(du -sb "$local_dir" | awk '{print $1}') -gz_bytes=$(stat -c %s "$archive") -log "compressed ${orig_bytes} → ${gz_bytes} bytes ($(( orig_bytes / (gz_bytes > 0 ? gz_bytes : 1) ))x)" - -log "uploading ${archive} → s3://${remote#s3://}" -# Retry transient `aws s3 cp` failures (rate limit / ELB blip / IAM -# role refresh hiccup) before giving up. Backoff 2s, 8s, 30s. -upload_ok=0 -for delay in 0 2 8 30; do - [ "$delay" -gt 0 ] && sleep "$delay" - if aws s3 cp --quiet "${archive}" "${remote}"; then - upload_ok=1 - break - fi - log "aws s3 cp failed; retrying after ${delay:-0}s (next attempt)" -done -if [ "$upload_ok" != "1" ]; then - echo "ERROR: aws s3 cp failed after retries; keeping ${archive} and ${local_dir} for manual recovery" >&2 - exit 6 -fi - -log "deleting local copies (${archive}, ${local_dir})" -rm -f "$archive" -rm -rf "$local_dir" - -log "snapshot ${ts} ok → ${remote}" diff --git a/benchmarks-website/ops/config/vortex-bench.env.example b/benchmarks-website/ops/config/vortex-bench.env.example deleted file mode 100644 index ee73097dd72..00000000000 --- a/benchmarks-website/ops/config/vortex-bench.env.example +++ /dev/null @@ -1,77 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Template for /etc/vortex-bench.env on the EC2 host. -# -# install.sh copies this template into place if /etc/vortex-bench.env is -# missing; the operator then fills in the secrets. The file must be -# mode 0600 owned by the user systemd runs the service as (ec2-user by -# default). systemd's EnvironmentFile= reads this for both the server -# unit and the deploy/backup timers. - -# Bearer token CI presents on POST /api/ingest. Constant-time compared. -# Generate with: openssl rand -hex 32 -INGEST_BEARER_TOKEN= - -# Bearer token operators present on /api/admin/snapshot and /api/admin/sql. -# Independent of INGEST_BEARER_TOKEN so the two can rotate separately. -# Generate with: openssl rand -hex 32 -ADMIN_BEARER_TOKEN= - -# DuckDB file the server opens. Lives outside any git checkout so a -# `git pull` never touches it. -VORTEX_BENCH_DB=/var/lib/vortex-bench/bench.duckdb - -# Where /api/admin/snapshot writes per-table Vortex snapshots (schema.sql -# plus one
.vortex file per table). backup.sh uploads the contents -# to S3 then deletes them, so this dir is transient. -VORTEX_BENCH_SNAPSHOT_DIR=/var/lib/vortex-bench/snapshots - -# Where DuckDB installs extensions. Defaults to -# `/duckdb-extensions`. The systemd unit's -# `ProtectHome=read-only` blocks DuckDB's default `~/.duckdb/extensions/...` -# install path, so this needs to live under a writable STATE_DIR subdir. -# Override only if you keep state outside /var/lib/vortex-bench. -#VORTEX_BENCH_EXTENSION_DIR=/var/lib/vortex-bench/duckdb-extensions - -# `host:port` the *public* listener binds to (ingest, read API, HTML, -# /health). Behind a reverse proxy (or just exposed directly on the -# EC2 SG), 0.0.0.0 is correct. -VORTEX_BENCH_BIND=0.0.0.0:3000 - -# `host:port` the *admin* listener binds to. Must remain loopback-only -# so `/api/admin/*` never reaches the public network even when -# VORTEX_BENCH_BIND opens 0.0.0.0. backup.sh / inspect.sh talk to this -# address via $ADMIN_URL. -VORTEX_BENCH_ADMIN_BIND=127.0.0.1:3001 - -# tracing-subscriber env filter spec. -VORTEX_BENCH_LOG=info,vortex_bench_server=info - -# --- ops scripts only (not consumed by the server itself) -------------- - -# Repo checkout the deploy timer pulls and builds from. Owned by the -# same user as the systemd services so `git pull` and `cargo build` don't -# need sudo. -REPO_DIR=/home/ec2-user/vortex - -# Branch the deploy timer tracks. -DEPLOY_BRANCH=develop - -# S3 prefix backup.sh syncs hourly snapshots to. The instance IAM role -# must already permit s3:PutObject under this prefix. -S3_BACKUP_PREFIX=s3://vortex-benchmark-results-database/v3-backups - -# URL of the *public* listener, used by deploy.sh's /health check. -SERVER_URL=http://127.0.0.1:3000 - -# URL of the *admin* listener, used by backup.sh (/api/admin/snapshot) -# and inspect.sh (/api/admin/sql). Defaults to http://127.0.0.1:3001 -# when unset; override only if you changed VORTEX_BENCH_ADMIN_BIND. -ADMIN_URL=http://127.0.0.1:3001 - -# Number of versioned binaries deploy.sh keeps under $BIN_DIR after a -# successful deploy. Defaults to 3 (current + previous + one buffer). -# Drop to 1 during a disk-pressure incident per the ops/README.md -# "Disk full" runbook, then bounce the deploy timer. -#KEEP_BINARIES=3 diff --git a/benchmarks-website/ops/deploy.sh b/benchmarks-website/ops/deploy.sh deleted file mode 100755 index 9dfd8d30442..00000000000 --- a/benchmarks-website/ops/deploy.sh +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Idempotent rebuild + restart, called by vortex-bench-deploy.timer -# every 60s. Cheap and silent on the common path (no new commits). -# -# Flow: -# 1. flock on a state file (concurrent runs bail). -# 2. git fetch origin $DEPLOY_BRANCH. -# 3. If origin SHA == last-deployed SHA → exit 0. -# 4. Else: git diff against a path filter. If nothing in the filter -# changed, sync the working tree (destructive checkout) to the -# new SHA, update the stamp, exit 0. (Skips a build for monorepo -# changes that don't touch the server.) -# 5. Else: sync working tree + cargo build --release -p vortex-bench-server. -# 6. Compare new binary's sha256 to the currently-running symlink target. -# If unchanged (cargo did no real work), update stamp + exit 0. -# 7. Else: copy to bin/vortex-bench-server.. (PID suffix -# breaks same-second deploy collisions), atomically swap the -# symlink (staging symlink + `mv -Tf` so the swap is rename(2)), -# sudo systemctl restart vortex-bench-server. -# 8. Wait for /health. On failure: revert symlink, restart the prior -# binary, re-probe /health (so a rollback to an also-broken -# binary is loud), do NOT update the stamp - next tick retries. -# 9. On success: update stamp, prune binary versions older than $KEEP_BINARIES. -# -# The working-tree sync is `git checkout --force --detach `, not -# `git pull --ff-only`, so the script survives force-pushes on the -# tracked branch. -# -# Exit codes: -# 0 success (either a real deploy or a clean no-op) -# 1 another deploy is in progress (lock held) -# 2 config error (missing env file, REPO_DIR, etc.) -# 3 git fetch failed -# 4 cargo build failed -# 5 systemctl restart failed -# 6 /health check failed; rolled back to previous binary successfully -# 7 /health check failed AND rollback to the previous binary ALSO -# failed /health - server is down, manual intervention required. -# This is the worst-case path; the timer will retry on next tick -# but the prior binary is itself broken, so the retry will not -# heal the host. - -set -euo pipefail - -ENV_FILE="${ENV_FILE:-/etc/vortex-bench.env}" -STATE_DIR="${STATE_DIR:-/var/lib/vortex-bench}" -LOCK_FILE="${LOCK_FILE:-${STATE_DIR}/.deploy.lock}" -STAMP_FILE="${STAMP_FILE:-${STATE_DIR}/last-deployed-sha}" -BIN_DIR="${BIN_DIR:-${STATE_DIR}/bin}" -BIN_SYMLINK="${BIN_DIR}/vortex-bench-server" -KEEP_BINARIES="${KEEP_BINARIES:-3}" - -log() { printf '[deploy %s] %s\n' "$(date -u +%H:%M:%SZ)" "$*"; } -err() { printf '[deploy %s] ERROR: %s\n' "$(date -u +%H:%M:%SZ)" "$*" >&2; } - -# --- Load env --- -if [ ! -f "$ENV_FILE" ]; then - err "missing ${ENV_FILE}" - exit 2 -fi -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a -: "${REPO_DIR:?REPO_DIR must be set in ${ENV_FILE}}" -: "${DEPLOY_BRANCH:=develop}" -: "${SERVER_URL:=http://127.0.0.1:3000}" - -if [ ! -d "${REPO_DIR}/.git" ]; then - err "${REPO_DIR} is not a git checkout" - exit 2 -fi - -# --- Lock --- -mkdir -p "$(dirname "$LOCK_FILE")" -exec 200>"$LOCK_FILE" -if ! flock -n 200; then - log "another deploy is in progress; bailing" - exit 1 -fi - -# Pick up cargo from the user's profile if not on PATH already. -# shellcheck disable=SC1091 -. "$HOME/.cargo/env" 2>/dev/null || true - -cd "$REPO_DIR" - -last_sha="" -[ -f "$STAMP_FILE" ] && last_sha="$(cat "$STAMP_FILE")" - -# --- Fetch --- -if ! git fetch --quiet origin "$DEPLOY_BRANCH"; then - err "git fetch origin ${DEPLOY_BRANCH} failed" - exit 3 -fi -new_sha="$(git rev-parse "origin/${DEPLOY_BRANCH}")" - -# --- Force mode --- -# A `FORCE=1` env var (or a `.force-rebuild` sentinel under the state -# dir) bypasses the SHA-unchanged fast path and treats this run as if -# nothing was ever deployed. Used by `force-rebuild.sh` for the -# "redeploy current branch right now" path. The sentinel is consumed -# so the next ordinary timer fire is a no-op again. -force=0 -if [ "${FORCE:-0}" = "1" ]; then - force=1 -fi -if [ -f "${STATE_DIR}/.force-rebuild" ]; then - rm -f "${STATE_DIR}/.force-rebuild" - force=1 -fi -if [ "$force" = "1" ]; then - log "force mode: ignoring stamp comparison and path filter" - last_sha="" -fi - -if [ "$force" = "0" ] && [ "$new_sha" = "$last_sha" ]; then - # Common case: nothing new since last fire. Silent on stdout to - # keep the journal clean. - exit 0 -fi - -# --- Path filter --- -# Rebuild + restart only when commits in the range touch website code, -# the workspace lockfile, or workspace Cargo manifests. Other changes -# (e.g. vortex-array fixes) update the working tree but don't restart. -filter_paths=( - benchmarks-website/server - benchmarks-website/migrate - Cargo.lock - Cargo.toml -) - -if [ -z "$last_sha" ] || ! git cat-file -e "${last_sha}^{commit}" 2>/dev/null; then - # First run, or stamp points at a commit we no longer have. Treat - # as "must rebuild" so we don't silently skip a real change. Clear - # last_sha in the vanished-commit branch too so the hash-equal - # restart-skip fast path below sees an empty last_sha and forces a - # full restart + /health verify (the prior stamp commit being gone - # means we can't trust whatever the symlink currently points at). - log "first run / unknown stamp '${last_sha:-}'; full rebuild" - last_sha="" - relevant_changed=1 -else - if git diff --name-only "${last_sha}" "${new_sha}" -- "${filter_paths[@]}" | grep -q .; then - relevant_changed=1 - else - relevant_changed=0 - fi -fi - -# --- Sync the working tree to origin/$DEPLOY_BRANCH --- -# `git pull --ff-only` breaks the moment the tracked branch is -# force-pushed (typical during PR iteration). The deploy worker's -# checkout is build-only - no human edits live here - so a destructive -# `git checkout --force --detach $new_sha` is the right semantics. -# Detached HEAD avoids any local-branch ref drift. -if ! git checkout --quiet --force --detach "$new_sha"; then - err "git checkout --force --detach ${new_sha} failed" - exit 3 -fi - -if [ "$relevant_changed" = "0" ]; then - log "no website-relevant paths changed in ${last_sha:0:7}..${new_sha:0:7}; skipping rebuild" - # Atomic stamp write so a kill mid-redirect cannot leave a truncated - # stamp the next tick would treat as a vanished commit. - printf '%s\n' "$new_sha" > "${STAMP_FILE}.tmp" - mv -f "${STAMP_FILE}.tmp" "$STAMP_FILE" - exit 0 -fi - -# --- Build --- -prev_short="${last_sha:0:7}" -log "building ${new_sha:0:7} (was ${prev_short:-})" -if ! cargo build --release --quiet -p vortex-bench-server; then - err "cargo build -p vortex-bench-server failed" - exit 4 -fi -new_binary="${REPO_DIR}/target/release/vortex-bench-server" -if [ ! -x "$new_binary" ]; then - err "expected binary not found at ${new_binary}" - exit 4 -fi - -# --- Compare hashes; skip restart if cargo produced byte-identical output --- -# Force mode (FORCE=1 / .force-rebuild) explicitly opts out of this fast -# path: the operator asked for "redeploy and reverify", not "skip if the -# byte image matches", so we must still restart + /health-poll. -# -# An empty last_sha (no stamp file, OR a stamp file we just rejected as -# pointing at a vanished commit at line 130) ALSO opts out: the only -# guarantee that the symlink's current target was health-verified comes -# from the stamp file being present. Without it the live binary could -# be a partially-applied prior deploy that crashed between symlink swap -# and the restart, in which case the byte-equal path would stamp -# success without ever restarting the server. -new_hash="$(sha256sum "$new_binary" | awk '{print $1}')" -current_hash="" -if [ -L "$BIN_SYMLINK" ] && [ -e "$BIN_SYMLINK" ]; then - current_hash="$(sha256sum "$BIN_SYMLINK" | awk '{print $1}')" -fi -if [ "$force" = "0" ] && [ -n "$last_sha" ] && [ "$new_hash" = "$current_hash" ]; then - log "binary unchanged (sha256 ${new_hash:0:12}); skipping restart" - printf '%s\n' "$new_sha" > "${STAMP_FILE}.tmp" - mv -f "${STAMP_FILE}.tmp" "$STAMP_FILE" - exit 0 -fi - -# --- Install + atomic symlink swap --- -# `ln -sfnT` is unlink+create - there is a brief window where $BIN_SYMLINK -# does not exist, and a concurrent reader (e.g. systemctl restart firing -# from another timer fire) would see ENOENT. Do the swap in two steps so -# the final transition is `rename(2)`, which IS atomic on POSIX: create -# the new symlink under a sibling name, then `mv -Tf` it onto $BIN_SYMLINK. -# Same pattern is used in both rollback paths below. -# Include a per-process suffix so two deploys within the same UTC -# second (e.g. timer fire racing with a manual force-rebuild) cannot -# collide on the versioned filename. Without it, the second `install` -# would overwrite the first's binary and `prev_target` could end up -# pointing at a path whose contents are not the prior binary anymore. -ts="$(date -u +%Y%m%dT%H%M%SZ)" -versioned="${BIN_DIR}/vortex-bench-server.${ts}.$$" -install -m 0755 "$new_binary" "$versioned" -prev_target="" -if [ -L "$BIN_SYMLINK" ]; then - prev_target="$(readlink "$BIN_SYMLINK")" -fi - -atomic_symlink() { - # $1 = symlink target, $2 = symlink path - local target="$1" path="$2" staging - staging="${path}.new.$$" - ln -snT -- "$target" "$staging" - mv -Tf -- "$staging" "$path" -} - -atomic_symlink "$versioned" "$BIN_SYMLINK" -log "swapped symlink → ${versioned}" - -# --- Restart + verify --- -if ! sudo /bin/systemctl restart vortex-bench-server; then - err "systemctl restart failed" - if [ -n "$prev_target" ]; then - atomic_symlink "$prev_target" "$BIN_SYMLINK" - # Rollback restart is best-effort; the worst case (rollback ALSO - # failed /health) escalates to exit 7 below via the same - # /health re-probe used by the /health-failure path. Without - # the re-probe, a doubly-broken host would silently exit 5 and - # the next timer tick would loop on the same broken binary. - sudo /bin/systemctl restart vortex-bench-server || true - roll_deadline=$(( $(date +%s) + 30 )) - roll_healthy=0 - while [ "$(date +%s)" -lt "$roll_deadline" ]; do - if curl -fsS --max-time 3 "${SERVER_URL}/health" >/dev/null 2>&1; then - roll_healthy=1 - break - fi - sleep 1 - done - if [ "$roll_healthy" = "1" ]; then - log "rolled back symlink to ${prev_target} (verified healthy after systemctl-restart failure)" - exit 5 - fi - err "rollback to ${prev_target} ALSO failed /health - server is down; manual intervention required" - exit 7 - fi - exit 5 -fi - -# Give it a moment to come up, then poll /health. -deadline=$(( $(date +%s) + 30 )) -healthy=0 -while [ "$(date +%s)" -lt "$deadline" ]; do - if curl -fsS --max-time 3 "${SERVER_URL}/health" >/dev/null 2>&1; then - healthy=1 - break - fi - sleep 1 -done -if [ "$healthy" != "1" ]; then - err "/health did not respond within 30s - rolling back" - if [ -n "$prev_target" ]; then - atomic_symlink "$prev_target" "$BIN_SYMLINK" - sudo /bin/systemctl restart vortex-bench-server || true - # Verify the rolled-back binary is itself healthy before claiming - # clean rollback. A "previous binary" that's also broken (e.g. a - # prior failed deploy nobody caught) needs a louder signal. - roll_deadline=$(( $(date +%s) + 30 )) - roll_healthy=0 - while [ "$(date +%s)" -lt "$roll_deadline" ]; do - if curl -fsS --max-time 3 "${SERVER_URL}/health" >/dev/null 2>&1; then - roll_healthy=1 - break - fi - sleep 1 - done - if [ "$roll_healthy" = "1" ]; then - log "rolled back symlink to ${prev_target} (verified healthy)" - exit 6 - fi - err "rollback to ${prev_target} ALSO failed /health - server is down; manual intervention required" - exit 7 - else - err "no previous binary to roll back to" - fi - exit 6 -fi - -# --- Success: update stamp, prune old binaries --- -printf '%s\n' "$new_sha" > "${STAMP_FILE}.tmp" -mv -f "${STAMP_FILE}.tmp" "$STAMP_FILE" -log "deploy ok: ${new_sha:0:7} → live (binary ${ts})" - -# Keep the most recent $KEEP_BINARIES versioned binaries, drop the rest. -# Glob is restricted to the digit-prefix timestamp form so a stale staging -# symlink (`vortex-bench-server.new.`) left over from a killed -# `atomic_symlink` call cannot be picked up here and either inflate the -# keep-N count or be selected for deletion as if it were an old binary. -mapfile -t binaries < <(ls -1 "${BIN_DIR}"/vortex-bench-server.[0-9]* 2>/dev/null | sort) -if [ "${#binaries[@]}" -gt "$KEEP_BINARIES" ]; then - drop_count=$(( ${#binaries[@]} - KEEP_BINARIES )) - for b in "${binaries[@]:0:$drop_count}"; do - # Never delete what the symlink currently points at. - if [ "$b" != "$(readlink -f "$BIN_SYMLINK")" ]; then - rm -f "$b" - log "pruned ${b}" - fi - done -fi diff --git a/benchmarks-website/ops/force-rebuild.sh b/benchmarks-website/ops/force-rebuild.sh deleted file mode 100755 index 61a3e2d1363..00000000000 --- a/benchmarks-website/ops/force-rebuild.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Force a rebuild + restart of origin/$DEPLOY_BRANCH right now, even -# if origin hasn't moved since the last successful deploy. Drops a -# sentinel that the next deploy.sh fire consumes, then triggers it. -# -# Use cases: -# - You changed /etc/vortex-bench.env and want a fresh binary build -# (e.g. a feature flag baked into config) rather than just a -# `systemctl restart` of the existing one. -# - You flipped DEPLOY_BRANCH and want the new tip in <60s rather -# than waiting for the timer. -# - Build artefacts got wedged and you want a clean rebuild. -# -# For "build whatever I have locally checked out" rather than fetching -# origin, edit /etc/vortex-bench.env to point DEPLOY_BRANCH at a -# branch the local tip is already on, then run this. The deploy -# script always builds origin's tip - there is no "use local HEAD" -# mode by design; push to a branch first. - -set -euo pipefail - -STATE_DIR="${STATE_DIR:-/var/lib/vortex-bench}" - -if [ ! -d "$STATE_DIR" ]; then - echo "ERROR: ${STATE_DIR} not found - has install.sh run?" >&2 - exit 2 -fi - -# The sentinel file needs to be writable by the user the deploy -# service runs as. install.sh chowns STATE_DIR to that user, so this -# works without sudo. If you're running as a different user, sudo. -if ! touch "${STATE_DIR}/.force-rebuild" 2>/dev/null; then - echo "ERROR: cannot write ${STATE_DIR}/.force-rebuild - run as the install user or sudo" >&2 - exit 2 -fi - -echo "[force-rebuild] sentinel dropped; firing deploy service" -sudo /bin/systemctl start vortex-bench-deploy.service -echo "[force-rebuild] tail with: journalctl -fu vortex-bench-deploy.service" diff --git a/benchmarks-website/ops/inspect.sh b/benchmarks-website/ops/inspect.sh deleted file mode 100755 index 5362a6f5c98..00000000000 --- a/benchmarks-website/ops/inspect.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Run a read-only SQL query against the live DuckDB without stopping -# the server. Calls /api/admin/sql and prints the duckdb-cli-style -# table. -# -# Usage: -# ./inspect.sh "SELECT COUNT(*) FROM commits;" -# echo "PRAGMA table_info('commits');" | ./inspect.sh -# ./inspect.sh -j "SELECT * FROM compression_sizes LIMIT 3" # raw json -# -# The server allows SELECT, WITH, PRAGMA, SHOW, DESCRIBE, EXPLAIN. -# Anything else is rejected with 403 by the server (so a typo'd UPDATE -# can't run). - -set -euo pipefail - -ENV_FILE="${ENV_FILE:-/etc/vortex-bench.env}" -if [ ! -f "$ENV_FILE" ]; then - echo "ERROR: missing ${ENV_FILE}" >&2 - exit 2 -fi -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a -: "${ADMIN_BEARER_TOKEN:?ADMIN_BEARER_TOKEN must be set in ${ENV_FILE}}" -# Admin SQL lives on the loopback-only admin listener; the public bind -# (`SERVER_URL`) does not match `/api/admin/*` at all. -: "${ADMIN_URL:=http://127.0.0.1:3001}" - -format=table -if [ "${1:-}" = "-j" ] || [ "${1:-}" = "--json" ]; then - format=json - shift -fi - -if [ -n "${1:-}" ]; then - sql="$1" -else - sql="$(cat)" -fi - -# Build the JSON body with `jq --arg` so quoting in the SQL is a non-issue -# (or python3's json.dumps if jq is missing). -if command -v jq >/dev/null 2>&1; then - body=$(jq -nc --arg sql "$sql" '{sql: $sql}') -elif command -v python3 >/dev/null 2>&1; then - body=$(python3 -c 'import json,sys; print(json.dumps({"sql": sys.argv[1]}))' "$sql") -else - echo "ERROR: install jq or python3 to call /api/admin/sql safely" >&2 - exit 2 -fi - -# Write the Authorization header to a 0600 file and pass via `-H @path` -# so the bearer token never appears in argv (visible to anyone reading -# `ps aux`). Same pattern in backup.sh. -scratch="$(mktemp -d "${TMPDIR:-/tmp}/vortex-bench-inspect.XXXXXX")" -trap 'rm -rf "$scratch"' EXIT -auth_header="${scratch}/auth.hdr" -umask 077 -# Suppress xtrace for the one line that holds the bearer so `bash -x inspect.sh` -# does not leak the token to stderr. -{ _xtrace="$(set +o | grep xtrace)"; set +x; } 2>/dev/null -printf 'Authorization: Bearer %s\n' "${ADMIN_BEARER_TOKEN}" > "$auth_header" -eval "$_xtrace" 2>/dev/null || true - -curl -fsS \ - -X POST \ - -H "@${auth_header}" \ - -H "Content-Type: application/json" \ - --data-binary "$body" \ - "${ADMIN_URL}/api/admin/sql?format=${format}" -echo diff --git a/benchmarks-website/ops/install.sh b/benchmarks-website/ops/install.sh deleted file mode 100755 index 244aac4b52f..00000000000 --- a/benchmarks-website/ops/install.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# One-time bootstrap of vortex-bench-server on a fresh EC2 host. -# Idempotent - safe to re-run after editing units or to recover from -# partial state. See ops/README.md for the full operator runbook. -# -# Run as a user with sudo (typically ec2-user). The script will: -# 1. Create state directories under /var/lib/vortex-bench, owned by -# $RUN_USER. Logs go to journalctl, not to a file. -# 2. Drop a sudoers fragment that lets $RUN_USER restart the server -# service without a password (so the deploy timer can run as the -# service user). -# 3. Copy /etc/vortex-bench.env from the template if missing (mode 0600). -# 4. Install the systemd units and reload systemd. -# 5. Symlink the ops/ directory into /var/lib/vortex-bench so the -# systemd units have a stable path (the repo can move). -# 6. Enable + start the server, deploy timer, and backup timer. -# -# Usage: -# ./benchmarks-website/ops/install.sh -# REPO_DIR=$HOME/vortex ./benchmarks-website/ops/install.sh -# -# Only REPO_DIR is honored as an env override; the run user, state-dir, -# env-file, systemd-dir, and sudoers-file paths are pinned (they have -# to match the shipped systemd units, which hard-code these values). - -set -euo pipefail - -# The installed systemd units hard-code `User=ec2-user`, -# `EnvironmentFile=/etc/vortex-bench.env`, and the -# `/var/lib/vortex-bench` state-dir. Keep these values aligned with -# the units in `systemd/` and the runbook in `README.md`; the script -# does NOT template the units at install time. Anyone running on a -# different user / state-dir / env-file path needs to hand-edit the -# units before this script copies them into /etc/systemd/system. -RUN_USER="ec2-user" -RUN_GROUP="${RUN_USER}" -REPO_DIR="${REPO_DIR:-$HOME/vortex}" -STATE_DIR="/var/lib/vortex-bench" -ENV_FILE="/etc/vortex-bench.env" -SYSTEMD_DIR="/etc/systemd/system" -SUDOERS_FILE="/etc/sudoers.d/vortex-bench" - -ops_dir="${REPO_DIR}/benchmarks-website/ops" -if [ ! -d "$ops_dir" ]; then - echo "ERROR: ${ops_dir} not found. Set REPO_DIR=." >&2 - exit 2 -fi - -# Preflight: every external command the autopilot will call from this -# point on. Fail loudly here with a one-line summary instead of producing -# systemd units that 5xx silently in the journal hours later. -missing=() -for cmd in git cargo openssl jq flock aws tar gzip curl; do - command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") -done -if [ "${#missing[@]}" -gt 0 ]; then - echo "ERROR: missing required commands: ${missing[*]}" >&2 - echo " (install per BOOTSTRAP.md Phase 2 before re-running install.sh)" >&2 - exit 2 -fi - -# The deploy timer runs as ${RUN_USER} with no SSH agent, so an SSH -# remote fails with "Permission denied (publickey)" on every fire. -# Public-repo HTTPS reads need no auth - warn early so this is not the -# first surprise out of the gate. -if [ -d "${REPO_DIR}/.git" ]; then - origin_url="$(git -C "$REPO_DIR" remote get-url origin 2>/dev/null || true)" - case "$origin_url" in - git@*|ssh://*) - echo "WARNING: ${REPO_DIR}'s origin is ${origin_url}." >&2 - echo " The deploy timer cannot fetch over SSH (no agent). Fix with:" >&2 - echo " git -C ${REPO_DIR} remote set-url origin https://github.com/vortex-data/vortex.git" >&2 - ;; - esac -fi - -log() { printf '[install] %s\n' "$*"; } - -# --- 1. State directories --- -log "creating ${STATE_DIR} (owner ${RUN_USER}:${RUN_GROUP})" -sudo install -d -m 0755 -o "$RUN_USER" -g "$RUN_GROUP" \ - "$STATE_DIR" \ - "${STATE_DIR}/bin" \ - "${STATE_DIR}/snapshots" \ - "${STATE_DIR}/duckdb-extensions" - -# --- 2. Sudoers fragment --- -# Let RUN_USER restart/start/stop/status the v3 systemd units without a -# password. Scope is intentionally tight: only the four units the -# autopilot owns, only the four verbs ops/migrate.sh and ops/deploy.sh -# call. The other v3 admin operations (editing /etc/vortex-bench.env, -# moving /var/lib/vortex-bench files) still require a full sudo session -# the operator can audit. -log "writing sudoers fragment to ${SUDOERS_FILE}" -# Write to a tempfile, validate with visudo, ONLY THEN move into place. -# A broken sudoers fragment in /etc/sudoers.d/ breaks sudo system-wide -# and removes the operator's repair path; the validate-before-rename -# pattern below makes a syntax error fail safely. -# -# Sudoers matches argv exactly. Each authorized invocation must be a -# SINGLE-unit `systemctl ` call; multi-unit invocations -# (`stop A B C`) would NOT be authorized by these per-unit lines, so -# every caller (deploy.sh, restart.sh, migrate.sh, force-rebuild.sh) -# splits its stops/starts into one call per unit. -sudoers_tmp="$(sudo mktemp /etc/sudoers.d/.vortex-bench.XXXXXX)" -sudo tee "$sudoers_tmp" >/dev/null </dev/null; then - sudo rm -f "$sudoers_tmp" - echo "ERROR: sudoers fragment failed visudo validation; refusing to install." >&2 - exit 3 -fi -sudo mv -f "$sudoers_tmp" "$SUDOERS_FILE" - -# --- 3. Env file --- -if [ ! -f "$ENV_FILE" ]; then - log "creating ${ENV_FILE} from template (mode 0600 owned by ${RUN_USER})" - sudo install -m 0600 -o "$RUN_USER" -g "$RUN_GROUP" \ - "${ops_dir}/config/vortex-bench.env.example" \ - "$ENV_FILE" - log "EDIT ${ENV_FILE} to set INGEST_BEARER_TOKEN, ADMIN_BEARER_TOKEN, REPO_DIR" -else - log "${ENV_FILE} already present - leaving alone" -fi - -# --- 4. Symlink ops/ into the state dir --- -# Gives systemd units a stable path that doesn't depend on the repo -# checkout location moving. Stage-and-rename so the symlink is never -# missing for a window where a concurrent timer fire's ExecStart could -# ENOENT (matches the atomic-symlink pattern in deploy.sh). -log "symlinking ${ops_dir} -> ${STATE_DIR}/ops" -sudo ln -snT "$ops_dir" "${STATE_DIR}/ops.new" -sudo mv -Tf "${STATE_DIR}/ops.new" "${STATE_DIR}/ops" - -# --- 5. systemd units --- -log "installing systemd units to ${SYSTEMD_DIR}" -for unit in \ - vortex-bench-server.service \ - vortex-bench-deploy.service \ - vortex-bench-deploy.timer \ - vortex-bench-backup.service \ - vortex-bench-backup.timer -do - sudo install -m 0644 -o root -g root \ - "${ops_dir}/systemd/${unit}" \ - "${SYSTEMD_DIR}/${unit}" -done -sudo systemctl daemon-reload - -# --- 6. Enable (and start, if tokens are set) --- -# The server unit needs a binary at /var/lib/vortex-bench/bin/vortex-bench-server -# before it can start. If the symlink isn't there yet, the deploy timer -# will lay one down on its first run; until then the server will fail. -if [ ! -e "${STATE_DIR}/bin/vortex-bench-server" ]; then - log "no binary at ${STATE_DIR}/bin/vortex-bench-server yet" - log " → the first deploy-timer fire (after start) will build + install one." - log " → tail it with: journalctl -fu vortex-bench-deploy.service" -fi - -# Detect whether the operator has filled in the bearer tokens. An empty -# INGEST_BEARER_TOKEN makes the server fail startup; an empty -# ADMIN_BEARER_TOKEN leaves the admin listener unbound. Both cases mean -# starting the units now would just produce noisy failures - enable but -# defer the start instead. Source the env file in a subshell and test -# the runtime values: the prior `grep '^X=.+'` heuristic matched -# explicitly-empty `X=""` lines (the two quote characters satisfy `.+`) -# and started units that immediately failed validate_bearer_token. -tokens_set=$( - set -a - # shellcheck disable=SC1090 - . "$ENV_FILE" - set +a - if [ -n "${INGEST_BEARER_TOKEN:-}" ] && [ -n "${ADMIN_BEARER_TOKEN:-}" ]; then - echo yes - fi -) - -if [ "$tokens_set" = "yes" ]; then - log "tokens present in ${ENV_FILE} - enabling + starting deploy/backup timers" - # NB: we do NOT start vortex-bench-server.service here. The binary - # at /var/lib/vortex-bench/bin/vortex-bench-server does not exist - # until the deploy timer's first fire builds and installs one; the - # server then comes up automatically when deploy.sh runs `systemctl - # restart vortex-bench-server` after the symlink swap. Starting - # the server unit before that would just produce a noisy failure. - sudo systemctl enable --now vortex-bench-deploy.timer - sudo systemctl enable --now vortex-bench-backup.timer - sudo systemctl enable vortex-bench-server.service -else - log "tokens not set in ${ENV_FILE} - timers enabled but not started" - sudo systemctl enable vortex-bench-deploy.timer - sudo systemctl enable vortex-bench-backup.timer - sudo systemctl enable vortex-bench-server.service - log "after editing ${ENV_FILE}, run:" - log " sudo systemctl start vortex-bench-deploy.timer" - log " sudo systemctl start vortex-bench-backup.timer" - log " (server starts automatically on the deploy timer's first fire)" -fi - -log "" -log "install complete. Next steps:" -log " 1. Edit ${ENV_FILE} (chmod 0600, owned by ${RUN_USER}):" -log " - INGEST_BEARER_TOKEN=" -log " - ADMIN_BEARER_TOKEN=" -log " - confirm REPO_DIR points at the actual checkout" -log " 2. After starting the timers, watch the first deploy fire build the" -log " binary and bring the server up with an empty DuckDB:" -log " journalctl -fu vortex-bench-deploy.service" -log " curl http://127.0.0.1:3000/health" -log " 3. Populate the DB with the v2 to v3 migration (server is stopped" -log " and restarted automatically; see BOOTSTRAP.md Phase 7.A for the" -log " migrator's CLI flag set):" -log " ${STATE_DIR}/ops/migrate.sh run --help" -log " 4. (If preserving an existing \$HOME/bench.duckdb instead of" -log " re-migrating, copy it into place before step 3:" -log " sudo systemctl stop vortex-bench-server" -log " mv \$HOME/bench.duckdb ${STATE_DIR}/bench.duckdb" -log " sudo systemctl start vortex-bench-server" -log " and skip step 3.)" diff --git a/benchmarks-website/ops/migrate.sh b/benchmarks-website/ops/migrate.sh deleted file mode 100755 index 13ea316a36c..00000000000 --- a/benchmarks-website/ops/migrate.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Manual v2 to v3 migration wrapper. The migration tool needs exclusive -# access to the DB file, so the server is stopped first, the current DB -# is snapshotted to bench.prev-.duckdb for instant rollback, the -# migrate binary runs, and the server is started back up. -# -# Run from any directory while SSH'd onto the EC2 host. The args are -# passed through verbatim to `cargo run -p vortex-bench-migrate --`, so -# the operator owns the migrator's CLI surface (which has been changing -# while v3 stabilises). The wrapper only handles stop / snapshot prev -# DB / restart. -# -# Examples: -# /var/lib/vortex-bench/ops/migrate.sh run --output "$VORTEX_BENCH_DB" -# -# (Run as ec2-user is fine - we sudo only for systemctl.) - -set -euo pipefail - -ENV_FILE="${ENV_FILE:-/etc/vortex-bench.env}" -if [ ! -f "$ENV_FILE" ]; then - echo "ERROR: missing ${ENV_FILE}" >&2 - exit 2 -fi -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a -: "${REPO_DIR:?REPO_DIR must be set in ${ENV_FILE}}" -: "${VORTEX_BENCH_DB:?VORTEX_BENCH_DB must be set in ${ENV_FILE}}" -: "${SERVER_URL:=http://127.0.0.1:3000}" - -log() { printf '[migrate %s] %s\n' "$(date -u +%H:%M:%SZ)" "$*"; } - -if [ ! -d "${REPO_DIR}/.git" ]; then - echo "ERROR: REPO_DIR=${REPO_DIR} is not a git checkout" >&2 - exit 2 -fi - -# shellcheck disable=SC1091 -. "$HOME/.cargo/env" 2>/dev/null || true - -# Pause the autopilot for the duration of the migration. Stopping the -# timers alone is not enough - if deploy.service or backup.service is -# already mid-run, the active oneshot keeps going and can restart the -# server or call /api/admin/snapshot while the migrator owns the DB. -# Stop the services first (interrupting any active run, idempotent -# no-op if inactive), then the timers, then the server. -# -# The migration_succeeded flag is flipped to 1 only after the server -# comes back healthy. The trap restores the autopilot on success; on -# failure the autopilot stays paused so the operator can perform the -# documented mv-rollback without the deploy timer trying to re-fetch -# origin and run a fresh build on top of the half-rolled-back DB. -migration_succeeded=0 -# The sudoers fragment install.sh writes lists each unit on its own -# Cmnd line, and sudoers requires argv match exactly: multi-unit -# `systemctl stop A B C D` would NOT be authorized by per-unit entries -# and must be split into N single-unit invocations. Same on the -# success-restore path. We deliberately do NOT use `|| true` so a real -# sudo failure surfaces in the journal instead of silently no-op'ing the -# autopilot pause. Install the trap BEFORE the stop calls so a partial -# stop (one of the four sudo calls failing under set -e) still triggers -# the restore path. -restore_autopilot() { - if [ "$migration_succeeded" = "1" ]; then - log "restoring autopilot timers (deploy + backup)" - sudo /bin/systemctl start vortex-bench-deploy.timer - sudo /bin/systemctl start vortex-bench-backup.timer - else - log "migration did not complete - leaving autopilot timers stopped" - log " inspect with: systemctl status vortex-bench-server \\" - log " vortex-bench-deploy.service vortex-bench-deploy.timer \\" - log " vortex-bench-backup.service vortex-bench-backup.timer" - log " after rollback and verification, restart timers with:" - log " sudo systemctl start vortex-bench-deploy.timer" - log " sudo systemctl start vortex-bench-backup.timer" - fi -} -trap restore_autopilot EXIT - -log "stopping autopilot services (deploy + backup) + timers for migration window" -sudo /bin/systemctl stop vortex-bench-deploy.timer -sudo /bin/systemctl stop vortex-bench-deploy.service -sudo /bin/systemctl stop vortex-bench-backup.timer -sudo /bin/systemctl stop vortex-bench-backup.service - -log "stopping vortex-bench-server" -sudo /bin/systemctl stop vortex-bench-server - -# Snapshot the current DB so a botched migration can be reverted with -# one mv. WAL is folded in by DuckDB on next clean shutdown; if it -# survives a stop, copy it too. -ts="$(date -u +%Y%m%dT%H%M%SZ)" -prev="${VORTEX_BENCH_DB%.duckdb}.prev-${ts}.duckdb" -if [ -f "$VORTEX_BENCH_DB" ]; then - log "snapshotting ${VORTEX_BENCH_DB} → ${prev}" - cp -p "$VORTEX_BENCH_DB" "$prev" - [ -f "${VORTEX_BENCH_DB}.wal" ] && cp -p "${VORTEX_BENCH_DB}.wal" "${prev}.wal" -fi - -log "running cargo run --release -p vortex-bench-migrate -- (${#} args; argv NOT logged so future flags carrying secrets do not leak to journal)" -pushd "$REPO_DIR" >/dev/null -# Pass through whatever args the operator gave us. Don't inject a path -# flag - the migrator's CLI is owned by that crate. -if ! cargo run --release --quiet -p vortex-bench-migrate -- "$@"; then - popd >/dev/null - echo "ERROR: migration failed. Server is still stopped." >&2 - echo " Roll back:" >&2 - echo " mv \"$prev\" \"$VORTEX_BENCH_DB\"" >&2 - echo " [ -f \"${prev}.wal\" ] && mv \"${prev}.wal\" \"${VORTEX_BENCH_DB}.wal\" || true" >&2 - echo " Then start the server and re-enable autopilot timers:" >&2 - echo " sudo systemctl start vortex-bench-server" >&2 - echo " sudo systemctl start vortex-bench-deploy.timer" >&2 - echo " sudo systemctl start vortex-bench-backup.timer" >&2 - exit 3 -fi -popd >/dev/null - -log "starting vortex-bench-server" -sudo /bin/systemctl start vortex-bench-server - -# Give it a few seconds to come up. -deadline=$(( $(date +%s) + 30 )) -while [ "$(date +%s)" -lt "$deadline" ]; do - if curl -fsS --max-time 3 "${SERVER_URL}/health" >/dev/null 2>&1; then - migration_succeeded=1 - log "migrate ok - server is up" - log " prev DB kept at ${prev} (delete when you've verified data)" - exit 0 - fi - sleep 1 -done -echo "ERROR: server did not respond on /health within 30s" >&2 -# Stop the server BEFORE printing rollback instructions: the unit has -# Restart=on-failure RestartSec=2, so leaving it running would loop a -# broken/half-migrated binary against the new DB, and the rollback `mv` -# below races against the still-open file handle (on Linux the mv -# succeeds but the live server keeps writing to the unlinked inode). -# Do NOT swallow a `systemctl stop` failure with `|| true` here: if the -# stop fails (mid-procedure sudoers regression, systemd bus stuck), the -# rollback `mv` below races a still-running server and the operator -# corrupts the prev DB by following the printed instructions verbatim. -# Bail loudly so the operator fixes the stop path before any mv. -if ! sudo /bin/systemctl stop vortex-bench-server; then - echo "CRITICAL: 'sudo systemctl stop vortex-bench-server' failed." >&2 - echo " Do NOT execute the documented rollback 'mv' commands until the server is" >&2 - echo " verifiably stopped (check 'systemctl status vortex-bench-server')." >&2 - echo " The autopilot timers stay paused; debug the stop first." >&2 - exit 4 -fi -echo " server stopped. Roll back:" >&2 -echo " mv \"$prev\" \"$VORTEX_BENCH_DB\"" >&2 -echo " [ -f \"${prev}.wal\" ] && mv \"${prev}.wal\" \"${VORTEX_BENCH_DB}.wal\" || true" >&2 -echo " sudo systemctl start vortex-bench-server" >&2 -echo " sudo systemctl start vortex-bench-deploy.timer" >&2 -echo " sudo systemctl start vortex-bench-backup.timer" >&2 -exit 1 diff --git a/benchmarks-website/ops/restart.sh b/benchmarks-website/ops/restart.sh deleted file mode 100755 index 290675f9597..00000000000 --- a/benchmarks-website/ops/restart.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Restart the vortex-bench-server binary in place (no rebuild), with -# visible before/after state so you don't have to wonder if it worked. -# -# Prints, in order: -# - the running pid + start time + binary path the symlink points at, -# before the restart -# - the systemctl exit code -# - the running pid + start time + /health response after the restart -# - "RESTART OK" / "RESTART FAILED" + non-zero exit on failure -# -# Use this instead of `sudo systemctl restart vortex-bench-server` -# when you want any sign that it actually happened. - -set -euo pipefail - -ENV_FILE="${ENV_FILE:-/etc/vortex-bench.env}" -# Source the env file first so any SERVER_URL in /etc/vortex-bench.env is -# picked up, THEN apply the local default if both env-file and caller env -# left it unset. (Matches the sibling scripts; replaces the prior -# `default-then-source-then-no-op-default` shape that was misleading.) -if [ -f "$ENV_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$ENV_FILE" - set +a -fi -SERVER_URL="${SERVER_URL:-http://127.0.0.1:3000}" - -snap() { - # Use systemd as the source of truth for the running pid (matches - # whatever it would restart). Falls back to "?" if the unit is - # already inactive. - local pid started binary health - pid="$(systemctl show -p MainPID --value vortex-bench-server 2>/dev/null || echo 0)" - started="$(systemctl show -p ActiveEnterTimestamp --value vortex-bench-server 2>/dev/null || echo '?')" - if [ -L /var/lib/vortex-bench/bin/vortex-bench-server ]; then - binary="$(readlink /var/lib/vortex-bench/bin/vortex-bench-server)" - else - binary="?" - fi - health="$(curl -fsS --max-time 2 "${SERVER_URL}/health" 2>/dev/null \ - | (command -v jq >/dev/null && jq -c . || cat) \ - || echo '(unreachable)')" - printf ' pid: %s\n started: %s\n binary: %s\n /health: %s\n' \ - "$pid" "$started" "$binary" "$health" -} - -echo "BEFORE:" -snap - -echo -echo "running: sudo systemctl restart vortex-bench-server" -if ! sudo /bin/systemctl restart vortex-bench-server; then - echo "ERROR: systemctl restart returned non-zero" >&2 - echo - echo "AFTER (restart did not complete):" - snap - exit 1 -fi - -# Wait up to 30s for the new process to take requests. -deadline=$(( $(date +%s) + 30 )) -ok=0 -while [ "$(date +%s)" -lt "$deadline" ]; do - if curl -fsS --max-time 2 "${SERVER_URL}/health" >/dev/null 2>&1; then - ok=1 - break - fi - sleep 0.5 -done - -echo -echo "AFTER:" -snap - -if [ "$ok" = "1" ]; then - echo - echo "RESTART OK" - exit 0 -else - echo - echo "RESTART FAILED - /health did not respond within 30s" >&2 - echo "Inspect with: journalctl -u vortex-bench-server --since '1 min ago' --no-pager" >&2 - exit 1 -fi diff --git a/benchmarks-website/ops/systemd/vortex-bench-backup.service b/benchmarks-website/ops/systemd/vortex-bench-backup.service deleted file mode 100644 index 05bd56168fa..00000000000 --- a/benchmarks-website/ops/systemd/vortex-bench-backup.service +++ /dev/null @@ -1,47 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Oneshot unit invoked by vortex-bench-backup.timer hourly. Calls -# /api/admin/snapshot to land a per-table Vortex snapshot (schema.sql -# plus one
.vortex file per table) under -# $VORTEX_BENCH_SNAPSHOT_DIR//, `tar czf`s the directory into a -# single archive, then `aws s3 cp` uploads it to -# $S3_BACKUP_PREFIX/.tar.gz and deletes the local copies. -# -# PrivateTmp keeps the script's /tmp scratch (response capture) isolated -# from any concurrent manual `bash backup.sh` an operator might fire. - -[Unit] -Description=Vortex bench v3 hourly DB snapshot to S3 -After=vortex-bench-server.service network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -User=ec2-user -Group=ec2-user -EnvironmentFile=/etc/vortex-bench.env -WorkingDirectory=/var/lib/vortex-bench -ExecStart=/var/lib/vortex-bench/ops/backup.sh -StandardOutput=journal -StandardError=journal - -# Hardening - backup only needs to write snapshot scratch + tarballs in -# the state dir and call `aws s3 cp` via the instance role. -ProtectSystem=strict -ReadWritePaths=/var/lib/vortex-bench -ProtectHome=read-only -PrivateTmp=true -NoNewPrivileges=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectKernelLogs=true -ProtectControlGroups=true -RestrictNamespaces=true -RestrictSUIDSGID=true -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -LockPersonality=true -SystemCallFilter=@system-service -SystemCallFilter=~@privileged @resources -CapabilityBoundingSet= -AmbientCapabilities= diff --git a/benchmarks-website/ops/systemd/vortex-bench-backup.timer b/benchmarks-website/ops/systemd/vortex-bench-backup.timer deleted file mode 100644 index 3c1910fe892..00000000000 --- a/benchmarks-website/ops/systemd/vortex-bench-backup.timer +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Hourly snapshot timer. Persistent=true means a missed hour catches up -# after a reboot. - -[Unit] -Description=Vortex bench v3 hourly DB snapshot timer -Requires=vortex-bench-backup.service - -[Timer] -# Top of every hour, UTC. -OnCalendar=hourly -Persistent=true -RandomizedDelaySec=2min -Unit=vortex-bench-backup.service - -[Install] -WantedBy=timers.target diff --git a/benchmarks-website/ops/systemd/vortex-bench-deploy.service b/benchmarks-website/ops/systemd/vortex-bench-deploy.service deleted file mode 100644 index 2681b8a1baa..00000000000 --- a/benchmarks-website/ops/systemd/vortex-bench-deploy.service +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Oneshot unit invoked by vortex-bench-deploy.timer every 60s. Runs -# ops/deploy.sh, which is a no-op when origin/$DEPLOY_BRANCH hasn't -# moved or moved without touching benchmarks-website code. -# -# This unit deliberately runs as ec2-user - `git pull` and `cargo build` -# don't need root. The script asks for sudo only to `systemctl restart` -# vortex-bench-server, and the sudoers fragment ops/install.sh writes -# allows that single command without a password. - -[Unit] -Description=Vortex bench v3 auto-deploy -# Run after the server unit is up; if the server is failing, restarting -# it from the deploy script is what we want anyway. -After=vortex-bench-server.service network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -User=ec2-user -Group=ec2-user -EnvironmentFile=/etc/vortex-bench.env -WorkingDirectory=/var/lib/vortex-bench -ExecStart=/var/lib/vortex-bench/ops/deploy.sh -StandardOutput=journal -StandardError=journal -# Exit 1 = lock contention (another deploy is in progress). This is the -# common steady-state outcome when a manual force-rebuild races the timer -# fire, and is not a real failure - treat it as success so `systemctl -# status` does not show repeated failed runs in the journal. Real -# failures (exits 2 through 7) still surface as failed. -SuccessExitStatus=0 1 -# Deploy runs cargo build (writes to $HOME/.cargo and $REPO_DIR/target); -# hardening this unit beyond ec2-user privileges would break the build. -# The narrower hardening lives on vortex-bench-server.service. diff --git a/benchmarks-website/ops/systemd/vortex-bench-deploy.timer b/benchmarks-website/ops/systemd/vortex-bench-deploy.timer deleted file mode 100644 index e4536e958ff..00000000000 --- a/benchmarks-website/ops/systemd/vortex-bench-deploy.timer +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# Timer that fires the auto-deploy oneshot every 60s. AccuracySec=10s -# keeps the wakeups from coalescing too aggressively; the work itself -# is a no-op when nothing relevant changed. - -[Unit] -Description=Vortex bench v3 auto-deploy timer -Requires=vortex-bench-deploy.service - -[Timer] -# Fire 30s after boot, then every 60s. -OnBootSec=30s -OnUnitActiveSec=60s -AccuracySec=10s -# Catch up after a reboot in case OnUnitActiveSec slot was missed while -# the host was down. Matches vortex-bench-backup.timer's idiom; deploy -# itself is idempotent (path-filter + SHA-stamp + hash-equal-skip) so -# a missed-tick catch-up is at worst a no-op. -Persistent=true -Unit=vortex-bench-deploy.service - -[Install] -WantedBy=timers.target diff --git a/benchmarks-website/ops/systemd/vortex-bench-server.service b/benchmarks-website/ops/systemd/vortex-bench-server.service deleted file mode 100644 index 8113c29f351..00000000000 --- a/benchmarks-website/ops/systemd/vortex-bench-server.service +++ /dev/null @@ -1,61 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors -# -# systemd unit for vortex-bench-server. Owns the DuckDB file at -# $VORTEX_BENCH_DB and serves :3000. -# -# Installed by ops/install.sh into /etc/systemd/system/. Restart on -# crash, never give up. Start order: after a successful binary install -# at /var/lib/vortex-bench/bin/vortex-bench-server (the deploy timer -# atomically swaps this symlink in place; this unit is `restart`ed via -# the sudoers fragment ops/install.sh writes). - -[Unit] -Description=Vortex bench v3 server (bench.vortex.dev) -After=network-online.target -Wants=network-online.target -# A naive `systemctl start vortex-bench-server` before the deploy timer -# has laid down a binary at the symlink would emit a confusing -# `execve: No such file or directory` in the journal; gate on the -# symlink so the unit reports "condition failed, not started" instead. -ConditionPathExists=/var/lib/vortex-bench/bin/vortex-bench-server - -[Service] -Type=simple -User=ec2-user -Group=ec2-user -WorkingDirectory=/var/lib/vortex-bench -EnvironmentFile=/etc/vortex-bench.env -ExecStart=/var/lib/vortex-bench/bin/vortex-bench-server -Restart=on-failure -RestartSec=2 -TimeoutStopSec=60s -# `journalctl -u vortex-bench-server` is the canonical log. Keep stdout -# unbuffered so `tail -f` works. -Environment=RUST_BACKTRACE=1 -StandardOutput=journal -StandardError=journal - -# Conservative hardening - server only needs DB writes and outbound TCP -# (none in steady state, but cargo build's hyper would). -ProtectSystem=strict -ReadWritePaths=/var/lib/vortex-bench -ProtectHome=read-only -PrivateTmp=true -NoNewPrivileges=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectKernelLogs=true -ProtectControlGroups=true -RestrictNamespaces=true -RestrictSUIDSGID=true -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -LockPersonality=true -MemoryDenyWriteExecute=true -SystemCallFilter=@system-service -SystemCallFilter=~@privileged @resources -CapabilityBoundingSet= -AmbientCapabilities= - -[Install] -WantedBy=multi-user.target diff --git a/benchmarks-website/package-lock.json b/benchmarks-website/package-lock.json index 9f195e2b553..5e1682ef02a 100644 --- a/benchmarks-website/package-lock.json +++ b/benchmarks-website/package-lock.json @@ -379,9 +379,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -504,9 +504,9 @@ } }, "node_modules/concurrently": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.1.tgz", - "integrity": "sha512-vN61cP9COzctyhBqKUOh9YHqU70hYPnaHqNpXdBzM9PcwIpfPAvNK06hjm4gEJEGuvy4GBairO6uU32+FKpS4g==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, "license": "MIT", "dependencies": { @@ -972,9 +972,9 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -991,15 +991,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/rolldown": { diff --git a/benchmarks-website/server.js b/benchmarks-website/server.js index a9af96234fe..d6d3f98eef9 100644 --- a/benchmarks-website/server.js +++ b/benchmarks-website/server.js @@ -25,10 +25,10 @@ const USE_LOCAL_DATA = process.env.USE_LOCAL_DATA === "true"; // Benchmark groups: non-query groups + simple suites + fan-out suites const GROUPS = [ - "Random Access", "Compression", "Compression Size", ...QUERY_SUITES.filter((s) => !s.skip && !s.fanOut).map((s) => s.displayName), + "Random Access", ...FAN_OUT_GROUPS, ]; diff --git a/benchmarks-website/server/ARCHITECTURE.md b/benchmarks-website/server/ARCHITECTURE.md deleted file mode 100644 index e790b277a46..00000000000 --- a/benchmarks-website/server/ARCHITECTURE.md +++ /dev/null @@ -1,87 +0,0 @@ - - -# Benchmark Server Architecture - -The benchmark website is optimized around a materialized latest-100 read path. -DuckDB is the source of truth, but normal landing-page and group-open -hydration does not run SQL, serialize JSON, or compress responses per request. - -## Hot Read Path - -On startup the server builds a `ReadGeneration` from one DuckDB snapshot. That -generation contains precomputed JSON artifacts for: - -- `/api/groups` -- default `/api/chart/{slug}` latest-100 payloads -- default `/api/group/{slug}` latest-100 compatibility payloads -- versioned group shard payloads under - `/api/artifacts/{generation}/groups/{group_slug}/shards/{index}` - -Each artifact is stored in memory as identity, gzip, and brotli bytes. Request -handlers negotiate `Accept-Encoding` and serve those bytes directly with -`ETag`, `Vary: Accept-Encoding`, `Content-Length`, and cache headers. - -## Page Hydration - -The landing page and `/group/{slug}` render group metadata plus chart shells, -not inline chart payloads. Each group carries the active read generation, shard -count, and shard URL prefix. `chart-init.js` fetches shard 0 on intent or group -open so charts paint quickly, then queues the remaining latest-100 shards with -bounded per-tab concurrency. - -Latest-100 chart payloads include additive `history` metadata: - -- `total_commits`: full x-axis length for the chart -- `start_index`: where this payload starts in the full x-axis -- `loaded_commits`: number of loaded commits -- `complete`: whether the payload covers the full x-axis - -The client normalizes incomplete latest-100 payloads onto the full virtual -x-axis. Older unloaded commits are represented by blank labels and null series -values, so the range strip, zoom limits, and slider bounds behave as if the -whole history is present without fabricating data. - -## Full-History Warmup - -Opening a group queues `/api/chart/{slug}?n=all` for that group's charts in a -separate low-concurrency priority queue. A later-opened group gets higher -priority than queued work for older groups. If the user pans or zooms into an -unloaded virtual range before warmup finishes, that chart's queued full-history -request is promoted. In-flight requests are not cancelled. - -When the full payload arrives, the client replaces the virtual latest-100 -payload in place and preserves the current x-range when possible. - -## Fallback Paths - -`?n=all` and non-default `?n=` windows still use the DB-backed fallback path. -Those reads go through `QueryCache` single-flight entries and the DB read -semaphore so cold or unusual requests do not stampede DuckDB. Ingest writes do -not consume read permits. - -## Ingest And Rebuild - -Successful ingest invalidates `QueryCache` and schedules a read-model rebuild. -The active generation remains live while rebuilding. Repeated rebuild requests -coalesce, and a failed rebuild keeps serving the old generation. The server -keeps the active generation plus up to `RETAINED_PREVIOUS_GENERATIONS` previous -generations (currently 8) in a bounded `VecDeque` so already loaded pages can -continue resolving immutable shard URLs across a swap. The window is sized for -the worst observed CI dual-write burst; under-sizing surfaces as 404s on -`/api/artifacts/{generation}/.../shards/{i}` for stale tabs, which auto-recover -on the next group reopen. See `RETAINED_PREVIOUS_GENERATIONS` in -`src/read_model.rs`. - -## Main Files - -- `src/read_model.rs`: materialized generation and encoded artifact serving -- `src/api/mod.rs`: API routing between materialized artifacts and fallbacks -- `src/api/charts.rs`: chart DTO construction and `history` metadata -- `src/html/mod.rs`, `src/html/landing.rs`: shell/shard HTML rendering -- `static/chart-init.js`: virtual-axis normalization, shard hydration, and - full-history priority warmup -- `src/query_cache.rs`: single-flight fallback cache -- `src/db.rs`: DuckDB connection cloning and read backpressure diff --git a/benchmarks-website/server/Cargo.toml b/benchmarks-website/server/Cargo.toml deleted file mode 100644 index 8966a6fc154..00000000000 --- a/benchmarks-website/server/Cargo.toml +++ /dev/null @@ -1,67 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors - -[package] -name = "vortex-bench-server" -version = "0.1.0-alpha.0" -edition = "2024" -rust-version = "1.91.0" -license = "Apache-2.0" -description = "bench.vortex.dev v3 alpha server: HTTP API + HTML + DuckDB on local disk" -publish = false - -[lib] -name = "vortex_bench_server" -path = "src/lib.rs" - -[[bin]] -name = "vortex-bench-server" -path = "src/main.rs" - -# This is a leaf binary, not part of the vortex-* public API surface. -# Errors use anyhow / thiserror and the crate is intentionally outside -# the workspace. - -[dependencies] -anyhow = { workspace = true } -axum = "0.8" -base64 = "0.22" -brotli = "8.0.2" -bytes = "1.11" -dashmap = { workspace = true } -# track vortex-duckdb's bundled engine version (build.rs) -duckdb = { version = "1.10502", features = ["bundled"] } -flate2 = "1" -futures = { workspace = true, features = ["std"] } -maud = { version = "0.27", features = ["axum"] } -parking_lot = { workspace = true } -serde = { workspace = true, features = ["derive", "rc"] } -serde_json = { workspace = true } -subtle = "2.6" -thiserror = { workspace = true } -tokio = { workspace = true, features = [ - "rt-multi-thread", - "macros", - "net", - "signal", - "sync", -] } -tower = "0.5" -tower-http = { version = "0.6", features = [ - "compression-br", - "compression-gzip", - "trace", -] } -tracing = { workspace = true, features = ["std"] } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -twox-hash = "2.1" -vortex-utils = { workspace = true } - -[dev-dependencies] -# `filters` enables `Settings::add_filter`, which we use in -# `tests/common/mod.rs` to redact the build SHA from the HTML -# snapshots so they don't churn on every commit. -insta = { workspace = true, features = ["filters"] } -reqwest = { workspace = true, features = ["json"] } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] } diff --git a/benchmarks-website/server/build.rs b/benchmarks-website/server/build.rs deleted file mode 100644 index bd6f909c752..00000000000 --- a/benchmarks-website/server/build.rs +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use std::process::Command; - -fn main() { - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { - println!("cargo:rustc-link-lib=dylib=rstrtmgr"); - } - - // Capture the git SHA at build time so /health can confirm the - // running process matches what the deploy timer last saw. Emit the - // full 40-hex SHA so operators can compare directly to the value - // in `/var/lib/vortex-bench/last-deployed-sha` (also full SHA); - // the runbook tells them to verify equality with no truncation. - // Falls back to "unknown" outside a git checkout (e.g. shallow CI - // clones, source tarballs) so the build never fails on this. - let sha = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) - .unwrap_or_else(|| "unknown".to_owned()); - println!("cargo:rustc-env=VORTEX_BENCH_BUILD_SHA={sha}"); - - // HEAD covers the common deploy.sh path - // (`git checkout --force --detach `); refs/heads/* covers - // local branches if anyone runs the binary from a checked-out - // branch. Both are no-ops if the file doesn't exist. - println!("cargo:rerun-if-changed=../../.git/HEAD"); - println!("cargo:rerun-if-changed=../../.git/refs/heads"); -} diff --git a/benchmarks-website/server/fixtures/envelope.json b/benchmarks-website/server/fixtures/envelope.json deleted file mode 100644 index 14861f54a3c..00000000000 --- a/benchmarks-website/server/fixtures/envelope.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "run_meta": { - "benchmark_id": "fixture", - "schema_version": 1, - "started_at": "2026-04-25T00:00:00Z" - }, - "commit": { - "sha": "0123456789abcdef0123456789abcdef01234567", - "timestamp": "2026-04-25T00:00:00Z", - "message": "fixture commit", - "author_name": "Test Author", - "author_email": "author@example.com", - "committer_name": "Test Committer", - "committer_email": "committer@example.com", - "tree_sha": "fedcba9876543210fedcba9876543210fedcba98", - "url": "https://github.com/vortex-data/vortex/commit/0123456789abcdef0123456789abcdef01234567" - }, - "records": [ - { - "kind": "query_measurement", - "commit_sha": "0123456789abcdef0123456789abcdef01234567", - "dataset": "tpch", - "scale_factor": "1", - "query_idx": 1, - "storage": "nvme", - "engine": "datafusion", - "format": "vortex-file-compressed", - "value_ns": 1234567, - "all_runtimes_ns": [1200000, 1234567, 1300000], - "env_triple": "x86_64-linux-gnu" - }, - { - "kind": "compression_time", - "commit_sha": "0123456789abcdef0123456789abcdef01234567", - "dataset": "tpch-lineitem", - "format": "vortex-file-compressed", - "op": "encode", - "value_ns": 9999, - "all_runtimes_ns": [9000, 9999, 10500] - }, - { - "kind": "compression_size", - "commit_sha": "0123456789abcdef0123456789abcdef01234567", - "dataset": "tpch-lineitem", - "format": "vortex-file-compressed", - "value_bytes": 4242 - }, - { - "kind": "random_access_time", - "commit_sha": "0123456789abcdef0123456789abcdef01234567", - "dataset": "taxi", - "format": "vortex-file-compressed", - "value_ns": 555, - "all_runtimes_ns": [500, 555, 600] - }, - { - "kind": "vector_search_run", - "commit_sha": "0123456789abcdef0123456789abcdef01234567", - "dataset": "cohere-large-10m", - "layout": "partitioned", - "flavor": "vortex-turboquant", - "threshold": 0.75, - "value_ns": 7777, - "all_runtimes_ns": [7700, 7777, 7800], - "matches": 42, - "rows_scanned": 1000000, - "bytes_scanned": 5000000, - "iterations": 3 - } - ] -} diff --git a/benchmarks-website/server/src/admin.rs b/benchmarks-website/server/src/admin.rs deleted file mode 100644 index 0273d61468b..00000000000 --- a/benchmarks-website/server/src/admin.rs +++ /dev/null @@ -1,763 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Admin endpoints - bearer-gated DuckDB snapshot and read-only SQL. -//! -//! Mounted at `/api/admin/*` only when `ADMIN_BEARER_TOKEN` is set on the -//! server, surfaced through [`crate::app::AppState::with_admin`]. Both routes -//! require an `Authorization: Bearer ` header - the -//! `INGEST_BEARER_TOKEN` will not work here, so the two can rotate -//! independently. The operator workflow is documented in -//! `benchmarks-website/ops/README.md`. -//! -//! ## Routes -//! -//! ### `POST /api/admin/snapshot?ts=` -//! -//! Writes a snapshot directory `//` containing: -//! - `schema.sql` - concatenated DDL ([`crate::schema::COMMITS_DDL`] plus -//! every [`crate::family::FAMILIES`] entry's `schema_ddl`), so a -//! restore knows how to recreate the tables before bulk-loading. -//! - `
.vortex` for every table in [`crate::schema::TABLES`] - -//! each produced by a `COPY (SELECT * FROM
) TO … -//! (FORMAT vortex)`. The vortex DuckDB extension is auto-installed -//! from the DuckDB core extension repo on first call, then `LOAD`ed. -//! -//! Vortex compresses the BIGINT[] runtime arrays and string columns -//! roughly an order of magnitude better than gzipped CSV on this shape; -//! it is also the project's own format, which is the obvious dogfood. -//! -//! `ts` must match `[A-Za-z0-9_-]{1,64}`; the snapshot script -//! conventionally passes a UTC timestamp like `20260508T010000Z`. The -//! target subdirectory must not already exist (409 otherwise). All -//! per-table COPY statements run on a connection cloned from the -//! shared handle, so concurrent ingest writes are not blocked. -//! -//! ### `POST /api/admin/sql` -//! -//! Body: `{ "sql": "SELECT ..." }`. Query: `?format=json|table` (default -//! `json`). Only `SELECT`, `WITH`, `PRAGMA`, `SHOW`, `DESCRIBE`, and -//! `EXPLAIN` statements are allowed - anything else is rejected with 403. -//! Results are capped at `ADMIN_SQL_ROW_LIMIT` rows; responses past -//! that cap include `"truncated": true`. The handler runs each query on -//! its own cloned connection inside a `BEGIN TRANSACTION READ ONLY` -//! wrapper, so concurrent ingest writes proceed without contention. - -use std::fmt::Write as _; -use std::path::Path; -use std::path::PathBuf; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; - -use anyhow::Context as _; -use anyhow::Result; -use axum::Json; -use axum::extract::Query; -use axum::extract::Request; -use axum::extract::State; -use axum::http::StatusCode; -use axum::http::header::AUTHORIZATION; -use axum::http::header::CONTENT_TYPE; -use axum::middleware::Next; -use axum::response::IntoResponse; -use axum::response::Response; -use duckdb::Connection; -use duckdb::types::ValueRef; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value; -use serde_json::json; -use subtle::ConstantTimeEq; -use thiserror::Error; - -use crate::app::AppState; -use crate::db; -use crate::family; -use crate::schema; - -const ADMIN_SQL_ROW_LIMIT: usize = 10_000; - -/// Errors surfaced by `/api/admin/*` handlers. Auth (401) is handled by -/// [`require_admin_bearer`] and never reaches a handler. -#[derive(Debug, Error)] -pub enum AdminError { - /// 400 - request shape is malformed (bad `ts`, bad SQL JSON body, …). - #[error("bad request: {0}")] - BadRequest(String), - /// 403 - request is well-formed but the SQL statement is not on the - /// read-only allow-list. - #[error("forbidden: {0}")] - Forbidden(String), - /// 409 - snapshot target directory already exists. - #[error("conflict: {0}")] - Conflict(String), - /// 500 - anything else (DB error, IO error, …). - #[error("internal server error")] - Internal(#[from] anyhow::Error), -} - -impl IntoResponse for AdminError { - fn into_response(self) -> Response { - let (status, body) = match &self { - Self::BadRequest(msg) => ( - StatusCode::BAD_REQUEST, - json!({ "error": "bad_request", "message": msg }), - ), - Self::Forbidden(msg) => ( - StatusCode::FORBIDDEN, - json!({ "error": "forbidden", "message": msg }), - ), - Self::Conflict(msg) => ( - StatusCode::CONFLICT, - json!({ "error": "conflict", "message": msg }), - ), - Self::Internal(err) => { - tracing::error!(error = ?err, "admin internal error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - json!({ "error": "internal" }), - ) - } - }; - (status, Json(body)).into_response() - } -} - -/// Axum middleware enforcing the admin bearer token on `/api/admin/*`. -/// 401 if the header is missing, malformed, or wrong; 503 if the server -/// was started without `ADMIN_BEARER_TOKEN` (the admin router is unmounted -/// in that case, so this is just a defensive belt-and-braces check). -pub async fn require_admin_bearer( - State(state): State, - req: Request, - next: Next, -) -> Result { - let Some(expected) = state.admin_bearer_token.as_ref() else { - return Err(( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "admin_not_configured" })), - ) - .into_response()); - }; - let unauthorized = || { - ( - StatusCode::UNAUTHORIZED, - Json(json!({ "error": "unauthorized" })), - ) - .into_response() - }; - let header = req - .headers() - .get(AUTHORIZATION) - .ok_or_else(unauthorized)? - .to_str() - .map_err(|_| unauthorized())?; - let presented = header - .strip_prefix("Bearer ") - .ok_or_else(unauthorized)? - .as_bytes(); - if presented.ct_eq(expected.as_bytes()).into() { - Ok(next.run(req).await) - } else { - Err(unauthorized()) - } -} - -#[derive(Debug, Deserialize)] -pub struct SnapshotQuery { - /// Operator-supplied identifier for the snapshot, used as the leaf - /// directory name. Must match `[A-Za-z0-9_-]{1,64}`. - pub ts: String, -} - -#[derive(Debug, Serialize)] -pub struct SnapshotResponse { - /// Absolute path of the directory the export landed in. - pub snapshot_dir: String, -} - -/// Handler for `POST /api/admin/snapshot?ts=`. Writes -/// `schema.sql` plus one `
.vortex` file per fact/dim table into -/// a fresh subdirectory under [`AppState::snapshot_dir`]. -pub async fn snapshot( - State(state): State, - Query(q): Query, -) -> Result { - validate_ts(&q.ts)?; - let target: PathBuf = state.snapshot_dir.join(&q.ts); - - // Process-local `ts` reservation. Two concurrent calls with the - // same `ts` would otherwise both write tmp directories and then - // race at the `rename(2)` step - Linux silently overwrites an - // existing destination, so the loser's snapshot disappears with no - // signal. The reservation closes that race within a single - // `vortex-bench-server` process (the supported deployment). - let _ticket = SnapshotTicket::acquire(&state, &q.ts, &target)?; - - if target.exists() { - return Err(AdminError::Conflict(format!( - "snapshot directory already exists: {}", - target.display() - ))); - } - - let tmp = tmp_snapshot_dir(&target, &q.ts); - if tmp.exists() { - std::fs::remove_dir_all(&tmp) - .with_context(|| format!("removing stale temp snapshot dir {}", tmp.display()))?; - } - std::fs::create_dir_all(&tmp) - .with_context(|| format!("creating temp snapshot dir {}", tmp.display()))?; - - let result = write_snapshot(&state, &tmp).await; - if let Err(err) = result { - cleanup_partial(&tmp); - return Err(AdminError::Internal(err)); - } - // The ticket guarantees no other in-process call has the same `ts` - // reserved, so the final `rename(2)` will land cleanly. We still - // recheck `target.exists()` because a different process or an - // operator hand-creating the dir would also lose data on a silent - // overwrite. - if target.exists() { - cleanup_partial(&tmp); - return Err(AdminError::Conflict(format!( - "snapshot directory already exists: {}", - target.display() - ))); - } - if let Err(err) = std::fs::rename(&tmp, &target).with_context(|| { - format!( - "moving snapshot dir {} to {}", - tmp.display(), - target.display() - ) - }) { - cleanup_partial(&tmp); - return Err(AdminError::Internal(err)); - } - Ok(Json(SnapshotResponse { - snapshot_dir: target.display().to_string(), - })) -} - -/// RAII guard that holds a `ts` in [`AppState::pending_snapshots`] for the -/// duration of one `/api/admin/snapshot` call. Dropping the guard always -/// releases the reservation, even on panic or early-return error paths. -struct SnapshotTicket { - state: AppState, - ts: String, -} - -impl SnapshotTicket { - fn acquire(state: &AppState, ts: &str, target: &Path) -> Result { - let inserted = state.pending_snapshots.lock().insert(ts.to_string()); - if !inserted { - return Err(AdminError::Conflict(format!( - "snapshot for ts={ts} is already in flight (target {})", - target.display() - ))); - } - Ok(Self { - state: state.clone(), - ts: ts.to_string(), - }) - } -} - -impl Drop for SnapshotTicket { - fn drop(&mut self) { - self.state.pending_snapshots.lock().remove(&self.ts); - } -} - -/// Best-effort cleanup of a partially-written snapshot tmp dir. Logs the -/// failure rather than silently discarding it, so a wedge (disk full, -/// permission flip) is visible in the journal even when no automated -/// sweeper is wired up. -fn cleanup_partial(path: &Path) { - if let Err(err) = std::fs::remove_dir_all(path) { - // ENOENT just means the dir never got created or was already - // cleaned up by a sibling caller; ignore it. Anything else - // deserves a warn. - if err.kind() != std::io::ErrorKind::NotFound { - tracing::warn!( - path = %path.display(), - error = ?err, - "failed to clean up partial snapshot tmp dir; manual sweep may be needed" - ); - } - } -} - -/// Per-call unique temp directory used to stage a snapshot before the atomic -/// rename onto `target`. Includes a process-local counter so two concurrent -/// calls with the same `ts` in the same server process never share a staging -/// directory and clobber each other's in-progress writes. -fn tmp_snapshot_dir(target: &Path, ts: &str) -> PathBuf { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let id = COUNTER.fetch_add(1, Ordering::Relaxed); - target.with_file_name(format!("{ts}.tmp-{}-{}", std::process::id(), id)) -} - -async fn write_snapshot(state: &AppState, target: &Path) -> Result<()> { - // Schema is just our DDL string verbatim; restore reads this with - // `duckdb -init schema.sql` (or `.read schema.sql`) before - // bulk-loading the per-table vortex files. The DDL is assembled - // from the commits dim + every fact-table family in the same order - // `db::open()` applies them. - let mut schema_sql = String::with_capacity(8 * 1024); - schema_sql.push_str(schema::COMMITS_DDL); - for fam in family::FAMILIES { - schema_sql.push_str(fam.schema_ddl); - } - std::fs::write(target.join("schema.sql"), schema_sql) - .with_context(|| format!("writing schema.sql under {}", target.display()))?; - - let target_for_db = target.to_path_buf(); - db::run_blocking(&state.db, move |conn| { - export_snapshot_tables(conn, &target_for_db) - }) - .await -} - -fn export_snapshot_tables(conn: &mut Connection, target: &Path) -> Result<()> { - // Idempotent - `INSTALL` is a no-op if the extension is already - // present, `LOAD` is cheap once the binary is on disk. Vortex is a - // DuckDB core extension (not community), so the unqualified `INSTALL` - // hits the right repo on first call; subsequent calls are local. - // Runs outside the snapshot transaction because extension installation - // is not transactional. - conn.execute_batch("INSTALL vortex; LOAD vortex;") - .context("INSTALL/LOAD vortex extension")?; - - // All per-table COPYs share one `READ ONLY` transaction. Otherwise an - // ingest commit between the `commits` export and the - // `query_measurements` export yields an inconsistent backup - facts - // referencing a commit row that is not in the snapshot, or vice - // versa. The transaction's READ ONLY guard also belts-and-braces - // against the snapshot path accidentally writing. - conn.execute_batch("BEGIN TRANSACTION READ ONLY") - .context("begin read-only snapshot transaction")?; - if let Err(err) = copy_each_table(conn, target) { - if let Err(rb_err) = conn.execute_batch("ROLLBACK") { - tracing::warn!( - error = ?rb_err, - "rolling back snapshot read-only transaction failed; the original \ - export error (returned to the caller) is the actionable one" - ); - } - return Err(err); - } - conn.execute_batch("COMMIT") - .context("commit read-only snapshot transaction")?; - Ok(()) -} - -fn copy_each_table(conn: &Connection, target: &Path) -> Result<()> { - for table in schema::TABLES.iter() { - let path = target.join(format!("{table}.vortex")); - let path_str = path - .to_str() - .ok_or_else(|| anyhow::anyhow!("snapshot path is not UTF-8: {}", path.display()))?; - let sql = format!( - "COPY (SELECT * FROM {table}) TO {} (FORMAT vortex)", - db::sql_string_literal(path_str) - ); - conn.execute_batch(&sql) - .with_context(|| format!("COPY {table} TO {path_str}"))?; - } - Ok(()) -} - -fn validate_ts(ts: &str) -> Result<(), AdminError> { - if ts.is_empty() || ts.len() > 64 { - return Err(AdminError::BadRequest("ts must be 1..=64 chars".into())); - } - if !ts - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { - return Err(AdminError::BadRequest( - "ts must match [A-Za-z0-9_-]+".into(), - )); - } - Ok(()) -} - -#[derive(Debug, Deserialize)] -pub struct SqlBody { - pub sql: String, -} - -#[derive(Debug, Deserialize, Default)] -pub struct SqlQuery { - #[serde(default)] - pub format: SqlFormat, -} - -#[derive(Debug, Deserialize, Default, Clone, Copy)] -#[serde(rename_all = "lowercase")] -pub enum SqlFormat { - /// Returns `{ columns, rows, row_count }` JSON. - #[default] - Json, - /// Returns a `text/plain` ASCII table similar to `duckdb` CLI output. - Table, -} - -/// Handler for `POST /api/admin/sql`. -pub async fn sql( - State(state): State, - Query(q): Query, - Json(body): Json, -) -> Result { - validate_read_only(&body.sql)?; - let format = q.format; - let sql_text = body.sql; - let result = db::run_blocking(&state.db, move |conn| run_select(conn, &sql_text)) - .await - .map_err(AdminError::Internal)?; - Ok(match format { - SqlFormat::Json => Json(json!({ - "columns": result.columns, - "rows": result.rows, - "row_count": result.rows.len(), - "truncated": result.truncated, - })) - .into_response(), - SqlFormat::Table => ( - [(CONTENT_TYPE, "text/plain; charset=utf-8")], - format_table(&result), - ) - .into_response(), - }) -} - -/// Strips leading whitespace, parens, semicolons, and SQL comments (both `--` -/// line comments and `/* ... */` block comments) from `sql`. Returns the byte -/// offset of the first non-comment, non-whitespace token. Used by -/// [`validate_read_only`] so a query like `-- justify the call\nSELECT 1` is -/// not rejected with `only [...] are allowed; got ""`. -fn skip_leading_noise(sql: &str) -> usize { - let bytes = sql.as_bytes(); - let mut i = 0; - while i < bytes.len() { - let b = bytes[i]; - if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'(' || b == b';' { - i += 1; - continue; - } - if b == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' { - // Line comment runs to end of line. - i += 2; - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - continue; - } - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { - // Block comment, search for the matching `*/`. - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - if i + 1 < bytes.len() { - i += 2; - } else { - // Unterminated block comment; let the SQL parser surface - // the error rather than guessing. - return i; - } - continue; - } - break; - } - i -} - -fn validate_read_only(sql: &str) -> Result<(), AdminError> { - ensure_single_statement(sql)?; - let start = skip_leading_noise(sql); - let first_word: String = sql[start..] - .chars() - .take_while(|c| c.is_ascii_alphabetic()) - .collect::() - .to_ascii_uppercase(); - const ALLOWED: &[&str] = &["SELECT", "WITH", "PRAGMA", "SHOW", "DESCRIBE", "EXPLAIN"]; - if !ALLOWED.contains(&first_word.as_str()) { - return Err(AdminError::Forbidden(format!( - "only {ALLOWED:?} statements are allowed; got {first_word:?}" - ))); - } - Ok(()) -} - -fn ensure_single_statement(sql: &str) -> Result<(), AdminError> { - #[derive(Clone, Copy, PartialEq, Eq)] - enum State { - Normal, - SingleQuote, - DoubleQuote, - LineComment, - BlockComment, - } - - let mut state = State::Normal; - let mut chars = sql.char_indices().peekable(); - while let Some((idx, ch)) = chars.next() { - match state { - State::Normal => match ch { - '\'' => state = State::SingleQuote, - '"' => state = State::DoubleQuote, - '-' if chars.peek().is_some_and(|(_, next)| *next == '-') => { - chars.next(); - state = State::LineComment; - } - '/' if chars.peek().is_some_and(|(_, next)| *next == '*') => { - chars.next(); - state = State::BlockComment; - } - ';' => { - // Allow trailing whitespace and SQL comments after the - // terminator (`SELECT 1; -- note` and `SELECT 1; /* a */` - // are valid single statements). Only error if a - // non-comment, non-whitespace token follows. - let suffix_start = idx + ch.len_utf8(); - let after = skip_leading_noise(&sql[suffix_start..]); - if !sql[suffix_start + after..].is_empty() { - return Err(AdminError::Forbidden( - "admin SQL accepts a single statement only".into(), - )); - } - return Ok(()); - } - _ => {} - }, - State::SingleQuote => { - if ch == '\'' { - if chars.peek().is_some_and(|(_, next)| *next == '\'') { - chars.next(); - } else { - state = State::Normal; - } - } - } - State::DoubleQuote => { - if ch == '"' { - if chars.peek().is_some_and(|(_, next)| *next == '"') { - chars.next(); - } else { - state = State::Normal; - } - } - } - State::LineComment => { - if ch == '\n' { - state = State::Normal; - } - } - State::BlockComment => { - if ch == '*' && chars.peek().is_some_and(|(_, next)| *next == '/') { - chars.next(); - state = State::Normal; - } - } - } - } - Ok(()) -} - -struct QueryResult { - columns: Vec, - rows: Vec>, - truncated: bool, -} - -fn run_select(conn: &mut Connection, sql: &str) -> Result { - conn.execute_batch("BEGIN TRANSACTION READ ONLY") - .context("begin read-only admin SQL transaction")?; - let result = run_select_in_transaction(conn, sql); - match result { - Ok(value) => { - conn.execute_batch("COMMIT") - .context("commit read-only admin SQL transaction")?; - Ok(value) - } - Err(err) => { - if let Err(rb_err) = conn.execute_batch("ROLLBACK") { - tracing::warn!( - error = ?rb_err, - "rolling back admin SQL read-only transaction failed; the \ - original query error (returned to the caller) is the \ - actionable one" - ); - } - Err(err) - } - } -} - -fn run_select_in_transaction(conn: &Connection, sql: &str) -> Result { - let mut stmt = conn.prepare(sql).context("prepare SQL")?; - let mut rows_iter = stmt.query([]).context("execute SQL")?; - // duckdb-rs panics on Statement::column_names() if the statement has not - // executed yet - schema is only populated after `query()` runs. Pull it - // off the live `Rows` iterator instead. - let columns: Vec = rows_iter - .as_ref() - .map(|s| s.column_names()) - .unwrap_or_default(); - let column_count = columns.len(); - let mut rows: Vec> = Vec::new(); - let mut truncated = false; - while let Some(row) = rows_iter.next().context("row iter")? { - if rows.len() == ADMIN_SQL_ROW_LIMIT { - truncated = true; - break; - } - let mut out = Vec::with_capacity(column_count); - for i in 0..column_count { - let v = row.get_ref(i).context("get col")?; - out.push(value_ref_to_json(v)); - } - rows.push(out); - } - Ok(QueryResult { - columns, - rows, - truncated, - }) -} - -/// Coerce a DuckDB [`ValueRef`] into a JSON [`Value`] for the admin SQL API. -/// -/// `String::from_utf8_lossy` is used for `Text`: non-UTF-8 bytes in a TEXT -/// column are a misuse but not a reason to fail the request; the lossy -/// replacement (U+FFFD) surfaces so the caller can see something is wrong. -/// -/// `Decimal` is rendered via its Display impl. `Timestamp` is rendered as -/// `:` (one of `s|ms|us|ns:`) so it -/// round-trips through JSON unambiguously without pulling chrono / time -/// in as a dependency; consumers that want a human-readable ISO-8601 -/// can post-process the string. Other compound types (`List`, `Struct`, -/// `Array`, `Map`, `Union`, `Enum`) are rare in this database's schema; -/// they fall back to a best-effort Debug rendering tagged with the type -/// name so the caller can see something printable and we can extend -/// this match when we hit one. -fn value_ref_to_json(v: ValueRef<'_>) -> Value { - use duckdb::types::TimeUnit; - match v { - ValueRef::Null => Value::Null, - ValueRef::Boolean(b) => Value::Bool(b), - ValueRef::TinyInt(i) => Value::from(i), - ValueRef::SmallInt(i) => Value::from(i), - ValueRef::Int(i) => Value::from(i), - ValueRef::BigInt(i) => Value::from(i), - ValueRef::HugeInt(i) => Value::String(i.to_string()), - ValueRef::UTinyInt(i) => Value::from(i), - ValueRef::USmallInt(i) => Value::from(i), - ValueRef::UInt(i) => Value::from(i), - ValueRef::UBigInt(i) => Value::String(i.to_string()), - ValueRef::Float(f) => f64::from(f).into(), - ValueRef::Double(f) => f.into(), - ValueRef::Decimal(d) => Value::String(d.to_string()), - ValueRef::Timestamp(unit, raw) => { - // DuckDB stores timestamps as an integer count since - // 1970-01-01 UTC at the named precision. Surface them as a - // stable structured string keyed by the unit ("s:1700000000", - // "ms:1700000000000", etc.) so a future consumer can parse - // unambiguously without us reaching for chrono / time as a - // dependency in this slice. - let unit_str = match unit { - TimeUnit::Second => "s", - TimeUnit::Millisecond => "ms", - TimeUnit::Microsecond => "us", - TimeUnit::Nanosecond => "ns", - }; - Value::String(format!("{unit_str}:{raw}")) - } - ValueRef::Text(bytes) => Value::String(String::from_utf8_lossy(bytes).into_owned()), - ValueRef::Blob(_) => Value::String("".into()), - other => Value::String(format!("{other:?}")), - } -} - -fn format_table(r: &QueryResult) -> String { - if r.columns.is_empty() { - return "(no columns)\n".into(); - } - let row_strings: Vec> = r - .rows - .iter() - .map(|row| row.iter().map(value_display).collect()) - .collect(); - let mut widths: Vec = r.columns.iter().map(|c| c.chars().count()).collect(); - for row in &row_strings { - for (i, cell) in row.iter().enumerate() { - let w = cell.chars().count(); - if w > widths[i] { - widths[i] = w; - } - } - } - let mut out = String::new(); - write_separator(&mut out, &widths, '┌', '┬', '┐'); - write_row(&mut out, &r.columns, &widths); - write_separator(&mut out, &widths, '├', '┼', '┤'); - for row in &row_strings { - write_row(&mut out, row, &widths); - } - write_separator(&mut out, &widths, '└', '┴', '┘'); - // writeln! into a String only errors if the underlying Write impl - // returns one - fmt::Write for String is infallible - so the - // Result is discarded by design. - let _ = writeln!( - out, - "({} row{}{})", - r.rows.len(), - if r.rows.len() == 1 { "" } else { "s" }, - if r.truncated { "; truncated" } else { "" }, - ); - out -} - -fn value_display(v: &Value) -> String { - match v { - Value::Null => "NULL".into(), - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - other => other.to_string(), - } -} - -fn write_row>(out: &mut String, cells: &[S], widths: &[usize]) { - out.push('│'); - for (i, cell) in cells.iter().enumerate() { - let s = cell.as_ref(); - let pad = widths[i].saturating_sub(s.chars().count()); - out.push(' '); - out.push_str(s); - for _ in 0..pad { - out.push(' '); - } - out.push(' '); - out.push('│'); - } - out.push('\n'); -} - -fn write_separator(out: &mut String, widths: &[usize], left: char, mid: char, right: char) { - out.push(left); - for (i, w) in widths.iter().enumerate() { - if i > 0 { - out.push(mid); - } - for _ in 0..(*w + 2) { - out.push('─'); - } - } - out.push(right); - out.push('\n'); -} diff --git a/benchmarks-website/server/src/api/charts.rs b/benchmarks-website/server/src/api/charts.rs deleted file mode 100644 index 47e99c52058..00000000000 --- a/benchmarks-website/server/src/api/charts.rs +++ /dev/null @@ -1,642 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Per-chart payload assembly + the shared `SeriesAccumulator` glue. -//! -//! `chart_payload` dispatches on [`ChartKey`] to one of five -//! `collect_*_chart` functions, each of which runs one SQL query against -//! its fact table, threads the rows through a `SeriesAccumulator`, and -//! returns a [`ChartResponse`]. - -use std::collections::BTreeMap; -use std::sync::Arc; - -use anyhow::Context as _; -use anyhow::Result; -use duckdb::Connection; -use duckdb::ToSql; -use duckdb::params_from_iter; - -use super::dto::ChartHistory; -use super::dto::ChartResponse; -use super::dto::CommitPoint; -use super::dto::GroupChartsResponse; -use super::dto::NamedChartResponse; -use super::dto::SeriesTag; -use super::dto::UnitKind; -use super::groups::collect_groups; -use super::window::CommitWindow; -use crate::slug::ChartKey; -use crate::slug::GroupKey; - -/// Build the JSON payload for one chart by key. This is the shared -/// implementation behind `GET /api/chart/{slug}`, the inline `"}"#; - let out = escape_json_for_script(input); - assert!(!out.contains("` tags in `render::favicon_links`. -// v2's `public/favicon-*.png` set is unsuitable - those have white -// backgrounds baked in, so they render as a glaring white square on -// dark-mode tabs. -const ICON_LIGHT_PNG: &[u8] = include_bytes!("../../static/icon-light.png"); -const ICON_DARK_PNG: &[u8] = include_bytes!("../../static/icon-dark.png"); - -/// Cache-busting suffix appended to every static asset URL. Bump on a UI -/// release so cached browsers see the new bytes. -pub(crate) const STATIC_ASSET_VERSION: &str = "bench-v3-ui-27"; - -/// Append the cache-bust query param to a static asset URL. -pub(crate) fn versioned_asset(path: &str) -> String { - format!("{path}?v={STATIC_ASSET_VERSION}") -} - -pub(crate) async fn serve_chart_js() -> impl IntoResponse { - static_response(CHART_JS, "application/javascript; charset=utf-8") -} - -pub(crate) async fn serve_chart_zoom_js() -> impl IntoResponse { - static_response(CHART_ZOOM_JS, "application/javascript; charset=utf-8") -} - -pub(crate) async fn serve_chart_init_js() -> impl IntoResponse { - static_response(CHART_INIT_JS, "application/javascript; charset=utf-8") -} - -pub(crate) async fn serve_style_css() -> impl IntoResponse { - static_response(STYLE_CSS, "text/css; charset=utf-8") -} - -pub(crate) async fn serve_vortex_black_png() -> impl IntoResponse { - static_response(VORTEX_BLACK_PNG, "image/png") -} - -pub(crate) async fn serve_vortex_white_png() -> impl IntoResponse { - static_response(VORTEX_WHITE_PNG, "image/png") -} - -pub(crate) async fn serve_icon_light_png() -> impl IntoResponse { - static_response(ICON_LIGHT_PNG, "image/png") -} - -pub(crate) async fn serve_icon_dark_png() -> impl IntoResponse { - static_response(ICON_DARK_PNG, "image/png") -} - -fn static_response(bytes: &'static [u8], content_type: &'static str) -> Response { - ( - [ - (header::CONTENT_TYPE, content_type), - ( - header::CACHE_CONTROL, - "no-cache, max-age=0, must-revalidate", - ), - ], - bytes, - ) - .into_response() -} diff --git a/benchmarks-website/server/src/html/summary.rs b/benchmarks-website/server/src/html/summary.rs deleted file mode 100644 index 331cda64064..00000000000 --- a/benchmarks-website/server/src/html/summary.rs +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Group summary card rendering. -//! -//! Each [`Summary`] variant renders into a small `.benchmark-scores-summary` -//! card that lives above the chart grid. Every variant is rendered the same -//! shape — a list of `.score-item` rows — only the rank label, value, and -//! footer change. - -use maud::Markup; -use maud::html; - -use crate::api::Summary; - -/// Render the summary card for a group, or empty markup if `summary` is -/// `None` or every variant's content list is empty. -pub(super) fn summary_markup(summary: Option<&Summary>) -> Markup { - let Some(summary) = summary else { - return html! {}; - }; - match summary { - Summary::RandomAccess { - title, - rankings, - explanation, - } if !rankings.is_empty() => html! { - section.benchmark-scores-summary aria-label=(title) { - h3.scores-title { (title) } - div.scores-list { - @for (idx, item) in rankings.iter().enumerate() { - div.score-item { - span.score-rank { "#" (idx + 1) } - span.score-series title=(item.name) { (item.name) } - span.score-metrics { - span.score-value { (format_time_ns(item.time)) } - span.score-runtime { (format!("{:.2}x", item.ratio)) } - } - } - } - } - div.scores-explanation { (explanation) } - } - }, - Summary::Compression { - title, - compress_ratio, - decompress_ratio, - dataset_count: _, - explanation, - } if compress_ratio.is_some() || decompress_ratio.is_some() => html! { - section.benchmark-scores-summary aria-label=(title) { - h3.scores-title { (title) } - div.scores-list { - @if let Some(v) = compress_ratio { - div.score-item { - span.score-rank { "⚡" } - span.score-series { "Write Speed (Compression)" } - span.score-metrics { - span.score-value { (format!("{v:.2}x")) } - } - } - } - @if let Some(v) = decompress_ratio { - div.score-item { - span.score-rank { "📤" } - span.score-series { "Scan Speed (Decompression)" } - span.score-metrics { - span.score-value { (format!("{v:.2}x")) } - } - } - } - } - div.scores-explanation { (explanation) } - } - }, - Summary::CompressionSize { - title, - min_ratio, - mean_ratio, - max_ratio, - dataset_count: _, - explanation, - } => html! { - section.benchmark-scores-summary aria-label=(title) { - h3.scores-title { (title) } - div.scores-list { - div.score-item { - span.score-rank { "⬇️" } - span.score-series { "Min Size Ratio" } - span.score-metrics { - span.score-value { (format!("{min_ratio:.2}x")) } - } - } - div.score-item { - span.score-rank { "📊" } - span.score-series { "Mean Size Ratio" } - span.score-metrics { - span.score-value { (format!("{mean_ratio:.2}x")) } - } - } - div.score-item { - span.score-rank { "⬆️" } - span.score-series { "Max Size Ratio" } - span.score-metrics { - span.score-value { (format!("{max_ratio:.2}x")) } - } - } - } - div.scores-explanation { (explanation) } - } - }, - Summary::QueryBenchmark { - title, - rankings, - explanation, - } if !rankings.is_empty() => html! { - section.benchmark-scores-summary aria-label=(title) { - h3.scores-title { (title) } - div.scores-list { - @for (idx, item) in rankings.iter().enumerate() { - div.score-item { - span.score-rank { "#" (idx + 1) } - span.score-series title=(item.name) { (item.name) } - span.score-metrics { - span.score-value { (format!("{:.2}x", item.score)) } - span.score-runtime { (format_time_ns(item.total_runtime)) } - } - } - } - } - div.scores-explanation { (explanation) } - } - }, - _ => html! {}, - } -} - -fn format_time_ns(ns: f64) -> String { - let abs = ns.abs(); - if abs >= 1_000_000_000.0 { - format!("{:.2} s", ns / 1_000_000_000.0) - } else if abs >= 1_000_000.0 { - format!("{:.2} ms", ns / 1_000_000.0) - } else if abs >= 1_000.0 { - format!("{:.2} us", ns / 1_000.0) - } else { - format!("{ns:.0} ns") - } -} diff --git a/benchmarks-website/server/src/html/toolbar.rs b/benchmarks-website/server/src/html/toolbar.rs deleted file mode 100644 index 1075632c89b..00000000000 --- a/benchmarks-website/server/src/html/toolbar.rs +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Per-chart toolbar markup: the scope slider + Y-axis switch above each -//! chart, and the range scrollbar strip below it. - -use maud::Markup; -use maud::html; - -/// Render the per-chart toolbar. `idx` namespaces input ids so multiple -/// charts on the same page don't collide on ``. -/// -/// All buttons are `
.vortex` file per table). Default: -//! `/snapshots`. -//! - `VORTEX_BENCH_EXTENSION_DIR` - directory DuckDB installs extensions -//! into. Default: `/duckdb-extensions`. The -//! default lives under `STATE_DIR`, which the systemd unit makes -//! writable; this is what lets `INSTALL vortex` succeed under -//! `ProtectHome=read-only`. -//! - `VORTEX_BENCH_BIND` - `host:port` the **public** listener binds to. -//! Highest priority. Default `127.0.0.1:3000` (after `PORT` fallback). -//! Override to `0.0.0.0:3000` for container deploys. Only ingest, read, -//! HTML, and `/health` are served here - admin routes do not match. -//! - `PORT` - optional PaaS-conventional knob for the **public** listener -//! only. When set and `VORTEX_BENCH_BIND` is not, the public listener -//! binds `0.0.0.0:$PORT`. Does not affect the admin listener. -//! - `VORTEX_BENCH_ADMIN_BIND` - `host:port` the **admin** listener binds -//! to when `ADMIN_BEARER_TOKEN` is set. Default `127.0.0.1:3001`. The -//! address MUST resolve to a loopback IP (`127.0.0.0/8` or `::1`); the -//! server refuses to start otherwise. This is the load-bearing guarantee -//! that `/api/admin/*` never reaches the public network even when -//! `VORTEX_BENCH_BIND=0.0.0.0:3000`. Must also resolve to a different -//! address than the public bind. -//! - `VORTEX_BENCH_LOG` - `tracing-subscriber` env filter spec. Default -//! `info`. -//! -//! On Unix, SIGTERM and SIGINT both trigger a graceful drain - in-flight -//! requests are allowed to finish before the process exits. systemd's -//! `TimeoutStopSec` (default 90s) bounds the grace window, which matters -//! because `systemctl restart` is what the deploy timer fires on every -//! new binary roll. On non-Unix targets only Ctrl-C/SIGINT is wired. - -use std::env; -use std::net::SocketAddr; -use std::path::PathBuf; - -use anyhow::Context as _; -use anyhow::Result; -use anyhow::anyhow; -use futures::FutureExt; -use tokio::net::TcpListener; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_env("VORTEX_BENCH_LOG").unwrap_or_else(|_| EnvFilter::new("info")), - ) - .init(); - - let db_path: PathBuf = env::var("VORTEX_BENCH_DB") - .unwrap_or_else(|_| "bench.duckdb".to_string()) - .into(); - let bearer_token = - env::var("INGEST_BEARER_TOKEN").context("INGEST_BEARER_TOKEN env var must be set")?; - let admin_bearer_token = env::var("ADMIN_BEARER_TOKEN") - .ok() - .filter(|token| !token.trim().is_empty()); - // `VORTEX_BENCH_BIND` wins (full `host:port`). If unset, fall back to the - // PaaS-conventional `PORT` env var (binds to `0.0.0.0:$PORT`). Otherwise - // localhost-only on the default port. `PORT` only affects the public - // listener - the admin listener has its own env var below. - let public_bind = env::var("VORTEX_BENCH_BIND") - .ok() - .or_else(|| env::var("PORT").ok().map(|p| format!("0.0.0.0:{p}"))) - .unwrap_or_else(|| "127.0.0.1:3000".to_string()); - let admin_bind = - env::var("VORTEX_BENCH_ADMIN_BIND").unwrap_or_else(|_| "127.0.0.1:3001".to_string()); - - let mut state = vortex_bench_server::app::AppState::open(&db_path, bearer_token) - .with_context(|| format!("opening DuckDB at {}", db_path.display()))?; - if let Some(token) = admin_bearer_token { - state = state.with_admin(token); - } else { - tracing::warn!( - "ADMIN_BEARER_TOKEN is unset or empty - /api/admin/* will return 404 \ - (snapshot + read-only SQL disabled)" - ); - } - if let Ok(dir) = env::var("VORTEX_BENCH_SNAPSHOT_DIR") { - state = state.with_snapshot_dir(PathBuf::from(dir)); - } - if let Ok(dir) = env::var("VORTEX_BENCH_EXTENSION_DIR") { - state = state - .with_extension_dir(PathBuf::from(dir)) - .context("applying VORTEX_BENCH_EXTENSION_DIR")?; - } - let snapshot_dir = state.snapshot_dir.clone(); - let extension_dir = state.extension_dir.clone(); - let admin_app = vortex_bench_server::app::admin_router(state.clone()); - let public_app = vortex_bench_server::app::public_router(state); - - // Resolve and validate BOTH addresses before opening any listening - // socket. The earlier order opened the admin socket and only THEN - // checked the resolved address was loopback, which briefly bound a - // potentially-public address to the kernel's SYN queue before the - // refusal. Resolve via `tokio::net::lookup_host` (DNS-aware, matches - // what `TcpListener::bind` would use) and run both checks against the - // resolved `SocketAddr` first. - let public_addr = resolve_first_addr(&public_bind) - .await - .with_context(|| format!("resolving public listener bind {public_bind:?}"))?; - let admin_addr_opt = match admin_app.as_ref() { - Some(_) => Some( - resolve_first_addr(&admin_bind) - .await - .with_context(|| format!("resolving admin listener bind {admin_bind:?}"))?, - ), - None => None, - }; - if let Some(admin_addr) = admin_addr_opt { - ensure_admin_is_loopback(&admin_bind, admin_addr)?; - ensure_distinct_binds(public_addr, admin_addr)?; - } - - let public_listener = TcpListener::bind(public_addr) - .await - .with_context(|| format!("binding public listener to {public_addr}"))?; - let public_addr = public_listener.local_addr()?; - - let admin_listener = match (admin_app.as_ref(), admin_addr_opt) { - (Some(_), Some(admin_addr)) => { - let listener = TcpListener::bind(admin_addr) - .await - .with_context(|| format!("binding admin listener to {admin_addr}"))?; - let admin_addr = listener.local_addr()?; - // Defense-in-depth re-check on the post-bind address (a hostname - // resolution that drifted between our resolve and bind would be - // surprising; surface it rather than silently expose the port). - ensure_admin_is_loopback(&admin_bind, admin_addr)?; - Some((listener, admin_addr)) - } - _ => None, - }; - - tracing::info!( - public_addr = %public_addr, - admin_addr = ?admin_listener.as_ref().map(|(_, a)| *a), - db = %db_path.display(), - snapshot_dir = %snapshot_dir.display(), - extension_dir = %extension_dir.display(), - "bench server listening" - ); - - // Both listeners share one shutdown trigger so a single SIGTERM drains - // ingest, admin, and HTML in lockstep. `Shared` lets us hand the same - // future to each `with_graceful_shutdown`. - let shutdown = shutdown_signal().shared(); - match (admin_app, admin_listener) { - (Some(admin_app), Some((admin_listener, _))) => { - let public_fut = - axum::serve(public_listener, public_app).with_graceful_shutdown(shutdown.clone()); - let admin_fut = axum::serve(admin_listener, admin_app).with_graceful_shutdown(shutdown); - tokio::try_join!(public_fut, admin_fut)?; - } - _ => { - axum::serve(public_listener, public_app) - .with_graceful_shutdown(shutdown) - .await?; - } - } - Ok(()) -} - -/// Resolve a `host:port` spec to its first reachable [`SocketAddr`]. -async fn resolve_first_addr(spec: &str) -> Result { - tokio::net::lookup_host(spec) - .await - .with_context(|| format!("looking up {spec:?}"))? - .next() - .ok_or_else(|| anyhow!("{spec:?} did not resolve to any address")) -} - -/// Refuse to start if the public and admin listeners would land on the -/// same port. The admin listener exists specifically to keep -/// `/api/admin/*` off the public network, so the two collapsing back into -/// one is a silent rollback of that guarantee. -/// -/// Equality-of-`SocketAddr` alone misses the case where the public bind -/// is `0.0.0.0:3000` and the admin bind is `127.0.0.1:3000` (or any other -/// loopback + same port). The OS would EADDRINUSE at bind time, but the -/// error message ("Address already in use") gives no hint that -/// `VORTEX_BENCH_ADMIN_BIND` is the thing to change. Catch it here with -/// an actionable diagnostic instead. -fn ensure_distinct_binds(public: SocketAddr, admin: SocketAddr) -> Result<()> { - let port_collision = public.port() == admin.port() - && (public.ip() == admin.ip() - || public.ip().is_unspecified() - || admin.ip().is_unspecified()); - if port_collision { - return Err(anyhow!( - "public bind {public} and admin bind {admin} would overlap on port \ - {}; keep VORTEX_BENCH_ADMIN_BIND on a port distinct from the public listener", - public.port() - )); - } - Ok(()) -} - -/// Refuse to start if the admin listener resolved to a non-loopback -/// address. Without this guard, `VORTEX_BENCH_ADMIN_BIND=0.0.0.0:3001` -/// (or any public IP / unspecified address / non-loopback hostname) -/// would silently expose `/api/admin/*` - the bearer-gated SQL and -/// snapshot endpoints - to the public network. The contract is that the -/// admin listener is loopback-only and the only way callers reach it is -/// from the same host. An operator who genuinely wants remote admin -/// access should put it behind an SSH tunnel rather than opening the -/// bind, so this check is intentionally strict. -fn ensure_admin_is_loopback(spec: &str, admin: SocketAddr) -> Result<()> { - if !admin.ip().is_loopback() { - return Err(anyhow!( - "admin listener resolved to {admin} (from VORTEX_BENCH_ADMIN_BIND={spec:?}); \ - /api/admin/* must remain loopback-only. Use 127.0.0.1, ::1, or \ - a hostname that resolves to a loopback address; reach admin from \ - elsewhere via an SSH tunnel" - )); - } - Ok(()) -} - -/// Resolves when the process receives SIGINT or SIGTERM. Used as the -/// graceful-shutdown future for `axum::serve` so a `systemctl restart` -/// (SIGTERM) lets in-flight requests finish before the process exits. -/// `systemd`'s `TimeoutStopSec` (default 90s) bounds the grace window - -/// nothing inside the process imposes its own timeout. -async fn shutdown_signal() { - let ctrl_c = async { - tokio::signal::ctrl_c() - .await - .expect("install ctrl_c handler"); - }; - #[cfg(unix)] - let terminate = async { - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("install SIGTERM handler") - .recv() - .await; - }; - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => tracing::info!("received SIGINT - shutting down"), - _ = terminate => tracing::info!("received SIGTERM - shutting down"), - } -} - -#[cfg(test)] -mod tests { - use std::net::SocketAddr; - - use super::ensure_admin_is_loopback; - use super::ensure_distinct_binds; - - #[test] - fn admin_loopback_v4_passes() { - let addr: SocketAddr = "127.0.0.1:3001".parse().unwrap(); - ensure_admin_is_loopback("127.0.0.1:3001", addr).expect("127.0.0.1 is loopback"); - } - - #[test] - fn admin_loopback_v6_passes() { - let addr: SocketAddr = "[::1]:3001".parse().unwrap(); - ensure_admin_is_loopback("[::1]:3001", addr).expect("::1 is loopback"); - } - - #[test] - fn admin_loopback_127_8_subnet_passes() { - // The entire 127.0.0.0/8 block is loopback. - let addr: SocketAddr = "127.1.2.3:3001".parse().unwrap(); - ensure_admin_is_loopback("127.1.2.3:3001", addr).expect("127.0.0.0/8 is loopback"); - } - - #[test] - fn admin_zero_v4_rejected() { - let addr: SocketAddr = "0.0.0.0:3001".parse().unwrap(); - let err = ensure_admin_is_loopback("0.0.0.0:3001", addr) - .expect_err("0.0.0.0 must be rejected as non-loopback"); - let msg = err.to_string(); - assert!(msg.contains("loopback-only"), "{msg}"); - } - - #[test] - fn admin_zero_v6_rejected() { - let addr: SocketAddr = "[::]:3001".parse().unwrap(); - ensure_admin_is_loopback("[::]:3001", addr) - .expect_err(":: must be rejected as non-loopback"); - } - - #[test] - fn admin_public_ip_rejected() { - let addr: SocketAddr = "10.0.0.5:3001".parse().unwrap(); - ensure_admin_is_loopback("10.0.0.5:3001", addr) - .expect_err("private/public IP must be rejected as non-loopback"); - } - - #[test] - fn distinct_binds_passes() { - let p: SocketAddr = "127.0.0.1:3000".parse().unwrap(); - let a: SocketAddr = "127.0.0.1:3001".parse().unwrap(); - ensure_distinct_binds(p, a).expect("different ports are distinct"); - } - - #[test] - fn same_bind_rejected() { - let p: SocketAddr = "127.0.0.1:3000".parse().unwrap(); - let a: SocketAddr = "127.0.0.1:3000".parse().unwrap(); - ensure_distinct_binds(p, a).expect_err("identical binds must be rejected"); - } - - #[test] - fn unspecified_v4_collides_with_loopback_same_port() { - // The motivating case for the cycle-1 ensure_distinct_binds fix: - // public on 0.0.0.0:3000 + admin on 127.0.0.1:3000 would silently - // bind to overlapping ports; the OS would EADDRINUSE at bind time - // with no actionable diagnostic. Catch it pre-bind. - let p: SocketAddr = "0.0.0.0:3000".parse().unwrap(); - let a: SocketAddr = "127.0.0.1:3000".parse().unwrap(); - ensure_distinct_binds(p, a) - .expect_err("0.0.0.0:3000 + 127.0.0.1:3000 must be rejected as a port-collision"); - } - - #[test] - fn unspecified_v4_different_port_from_loopback_passes() { - let p: SocketAddr = "0.0.0.0:3000".parse().unwrap(); - let a: SocketAddr = "127.0.0.1:3001".parse().unwrap(); - ensure_distinct_binds(p, a).expect("distinct ports must not be rejected"); - } - - #[test] - fn unspecified_v6_collides_with_v6_loopback_same_port() { - let p: SocketAddr = "[::]:3000".parse().unwrap(); - let a: SocketAddr = "[::1]:3000".parse().unwrap(); - ensure_distinct_binds(p, a) - .expect_err("[::]:3000 + [::1]:3000 must be rejected as a port-collision"); - } -} diff --git a/benchmarks-website/server/src/query_cache.rs b/benchmarks-website/server/src/query_cache.rs deleted file mode 100644 index 3647ab920ec..00000000000 --- a/benchmarks-website/server/src/query_cache.rs +++ /dev/null @@ -1,472 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! In-memory query result cache for the read API. -//! -//! Every chart payload, group discovery result, and filter universe in the v3 -//! site is a deterministic function of the DuckDB snapshot — and that -//! snapshot only changes when `/api/ingest` lands a new envelope (~30 times a -//! day). Without a cache, every concurrent request re-runs the same SQL -//! against the engine, which serialises on DuckDB's internal locks; many tabs -//! / clients open at once peg the read API behind that lock even though the -//! underlying answer hasn't changed in hours. -//! -//! The cache is a generation-keyed, single-flight store of [`Arc`]-wrapped -//! payloads, one [`DashMap`] per result type: -//! - reads check the slot, clone the [`Arc`] out, and return — no DuckDB -//! round-trip on the hot path; -//! - the first miss for a key runs `compute` while concurrent waiters share the -//! same async slot; -//! - [`QueryCache::invalidate`] is called from [`crate::ingest`] after a -//! successful commit; it advances the generation and clears the visible -//! maps so old in-flight computes cannot repopulate the current snapshot. -//! -//! The two unkeyed slots — `/api/groups` and the filter universe — use a -//! [`DashMap`] with `()` as the logical key, so every slot in the cache is the -//! same primitive. -//! -//! Cached values are wrapped in [`std::sync::Arc`] and never deep-cloned on -//! the cache-hit path. The JSON bytes are still serialized per fallback -//! response; the materialized latest-100 hot path lives in -//! [`crate::read_model`]. - -use std::future::Future; -use std::hash::Hash; -use std::sync::Arc; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; - -use anyhow::Result; -use dashmap::DashMap; -use tokio::sync::Mutex as AsyncMutex; - -use crate::api::ChartResponse; -use crate::api::CommitWindow; -use crate::api::FilterUniverse; -use crate::api::Group; -use crate::api::GroupChartsResponse; - -/// Composite cache for every read-side DuckDB query. -/// -/// Cheap to clone via [`Arc`]; one instance is owned by [`crate::app::AppState`] -/// for the lifetime of the server. All entries are cleared by -/// [`Self::invalidate`] when ingest changes the underlying snapshot. -#[derive(Default)] -pub struct QueryCache { - /// Monotonically advances whenever ingest invalidates the read snapshot. - generation: AtomicU64, - /// `/api/groups` discovery result. Keyed by `()` because there is only - /// ever one group list per snapshot. - groups: DashMap, CacheSlot>>>, - /// Global filter universe (engines + formats). Also unkeyed. - filter_universe: DashMap, CacheSlot>>, - /// Per-chart payloads, keyed by `(slug, window)`. - chart_payloads: DashMap, CacheSlot>>>, - /// Per-group payloads, keyed by `(slug, window)`. - group_charts: DashMap, CacheSlot>>>, -} - -type CacheSlot = Arc>>; - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -struct VersionedKey { - generation: u64, - key: K, -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -struct ChartCacheKey { - slug: String, - window: CommitWindow, -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -struct GroupCacheKey { - slug: String, - window: CommitWindow, -} - -impl QueryCache { - /// Build an empty cache. Equivalent to [`Self::default`]. - pub fn new() -> Self { - Self::default() - } - - async fn get_or_compute( - &self, - map: &DashMap, CacheSlot>, - key: K, - compute: F, - wrap: Wrap, - ) -> Result - where - K: Clone + Eq + Hash, - V: Clone, - F: FnOnce() -> Fut, - Fut: Future>, - Wrap: FnOnce(Raw) -> V, - { - let generation = self.generation.load(Ordering::Acquire); - let cache_key = VersionedKey { generation, key }; - let slot = map - .entry(cache_key.clone()) - .or_insert_with(|| Arc::new(AsyncMutex::new(None))) - .clone(); - - let mut guard = slot.lock().await; - if let Some(value) = guard.as_ref() { - return Ok(value.clone()); - } - - let fresh = wrap(compute().await?); - if self.generation.load(Ordering::Acquire) == generation { - *guard = Some(fresh.clone()); - } else { - map.remove(&cache_key); - } - Ok(fresh) - } - - /// Get the cached `Arc>` from `/api/groups`, or run `compute` - /// if the slot is empty and store the result. - pub async fn groups(&self, compute: F) -> Result>> - where - F: FnOnce() -> Fut, - Fut: Future>>, - { - self.get_or_compute(&self.groups, (), compute, Arc::new) - .await - } - - /// Get the cached `Arc` for the global filter bar, or - /// run `compute` if the slot is empty and store the result. - pub async fn filter_universe(&self, compute: F) -> Result> - where - F: FnOnce() -> Fut, - Fut: Future>, - { - self.get_or_compute(&self.filter_universe, (), compute, Arc::new) - .await - } - - /// Get the cached chart payload for `(slug, window)`, or run `compute` - /// if the entry is absent and store the result. The cached value is - /// `Option>` so a confirmed "no data for this slug" - /// answer is cached too — repeated 404s do not re-hit DuckDB. - pub async fn chart_payload( - &self, - slug: &str, - window: &CommitWindow, - compute: F, - ) -> Result>> - where - F: FnOnce() -> Fut, - Fut: Future>>, - { - let key = ChartCacheKey { - slug: slug.to_string(), - window: *window, - }; - self.get_or_compute(&self.chart_payloads, key, compute, |value| { - value.map(Arc::new) - }) - .await - } - - /// Get the cached per-group payload for `(slug, window)`, or run - /// `compute` if the entry is absent and store the result. - pub async fn group_charts( - &self, - slug: &str, - window: &CommitWindow, - compute: F, - ) -> Result>> - where - F: FnOnce() -> Fut, - Fut: Future>>, - { - let key = GroupCacheKey { - slug: slug.to_string(), - window: *window, - }; - self.get_or_compute(&self.group_charts, key, compute, |value| { - value.map(Arc::new) - }) - .await - } - - /// Drop every cached value. Called from the ingest handler after a - /// successful commit so the next read sees the fresh snapshot. - pub fn invalidate(&self) { - self.generation.fetch_add(1, Ordering::AcqRel); - self.groups.clear(); - self.filter_universe.clear(); - self.chart_payloads.clear(); - self.group_charts.clear(); - } -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - use std::time::Duration; - - use anyhow::anyhow; - use tokio::sync::oneshot; - - use super::*; - use crate::api::FilterUniverse; - - fn empty_universe() -> FilterUniverse { - FilterUniverse::default() - } - - fn universe_with_engine(engine: &str) -> FilterUniverse { - FilterUniverse { - engines: vec![engine.to_string()], - formats: Vec::new(), - } - } - - #[tokio::test] - async fn singleton_caches_and_returns_same_arc() -> Result<()> { - let cache = QueryCache::new(); - let calls = AtomicUsize::new(0); - - let a = cache - .filter_universe(|| { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(empty_universe()) } - }) - .await?; - let b = cache - .filter_universe(|| { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(empty_universe()) } - }) - .await?; - - assert_eq!(calls.load(Ordering::SeqCst), 1, "compute should run once"); - assert!(Arc::ptr_eq(&a, &b), "cache returns the same Arc"); - Ok(()) - } - - #[tokio::test] - async fn invalidate_clears_singleton() -> Result<()> { - let cache = QueryCache::new(); - let calls = AtomicUsize::new(0); - - let a = cache - .filter_universe(|| { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(empty_universe()) } - }) - .await?; - cache.invalidate(); - let b = cache - .filter_universe(|| { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(empty_universe()) } - }) - .await?; - - assert_eq!( - calls.load(Ordering::SeqCst), - 2, - "invalidate should force a recompute" - ); - assert!( - !Arc::ptr_eq(&a, &b), - "post-invalidate read should produce a fresh Arc" - ); - Ok(()) - } - - #[tokio::test] - async fn concurrent_singleton_misses_share_one_compute() -> Result<()> { - let cache = Arc::new(QueryCache::new()); - let calls = Arc::new(AtomicUsize::new(0)); - let mut tasks = Vec::new(); - - for _ in 0..16 { - let cache = Arc::clone(&cache); - let calls = Arc::clone(&calls); - tasks.push(tokio::spawn(async move { - cache - .filter_universe(|| { - calls.fetch_add(1, Ordering::SeqCst); - async { - tokio::time::sleep(Duration::from_millis(50)).await; - Ok(empty_universe()) - } - }) - .await - })); - } - - for task in tasks { - task.await??; - } - - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "concurrent misses for one key should collapse to one compute" - ); - Ok(()) - } - - #[tokio::test] - async fn stale_in_flight_compute_does_not_repopulate_after_invalidate() -> Result<()> { - let cache = Arc::new(QueryCache::new()); - let (started_tx, started_rx) = oneshot::channel(); - let (release_tx, release_rx) = oneshot::channel(); - - let stale_cache = Arc::clone(&cache); - let stale_task = tokio::spawn(async move { - stale_cache - .filter_universe(|| { - started_tx.send(()).expect("test receiver is alive"); - async { - release_rx - .await - .expect("test sender releases stale compute"); - Ok(universe_with_engine("stale")) - } - }) - .await - }); - - started_rx.await?; - cache.invalidate(); - - let fresh = cache - .filter_universe(|| async { Ok(universe_with_engine("fresh")) }) - .await?; - assert_eq!(fresh.engines, ["fresh"]); - - release_tx.send(()).expect("stale compute is waiting"); - let stale = stale_task.await??; - assert_eq!(stale.engines, ["stale"]); - - let cached = cache - .filter_universe(|| async { Ok(universe_with_engine("unexpected")) }) - .await?; - assert_eq!( - cached.engines, - ["fresh"], - "old in-flight computations must not populate the new generation" - ); - Ok(()) - } - - #[tokio::test] - async fn chart_payload_keyed_by_slug_and_window() -> Result<()> { - let cache = QueryCache::new(); - let calls = AtomicUsize::new(0); - - let make = |display_name: &str| { - let display_name = display_name.to_string(); - ChartResponse { - display_name, - unit_kind: crate::api::UnitKind::TimeNs, - history: crate::api::ChartHistory { - total_commits: 0, - start_index: 0, - loaded_commits: 0, - complete: true, - }, - commits: Vec::new(), - series: serde_json::Map::new(), - series_meta: std::collections::BTreeMap::new(), - } - }; - - let one = cache - .chart_payload("a", &CommitWindow::All, || { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(Some(make("first"))) } - }) - .await? - .expect("Some"); - let _two = cache - .chart_payload("a", &CommitWindow::All, || { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(Some(make("second"))) } - }) - .await? - .expect("Some"); - // Different window — should be a separate cache slot. - let three = cache - .chart_payload( - "a", - &CommitWindow::Last(std::num::NonZeroU32::new(10).unwrap()), - || { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(Some(make("third"))) } - }, - ) - .await? - .expect("Some"); - - assert_eq!(calls.load(Ordering::SeqCst), 2); - assert_eq!(one.display_name, "first"); - assert_eq!(three.display_name, "third"); - Ok(()) - } - - #[tokio::test] - async fn chart_payload_caches_negative_result() -> Result<()> { - let cache = QueryCache::new(); - let calls = AtomicUsize::new(0); - - let none1 = cache - .chart_payload("missing", &CommitWindow::All, || { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(None) } - }) - .await?; - let none2 = cache - .chart_payload("missing", &CommitWindow::All, || { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(None) } - }) - .await?; - - assert!(none1.is_none() && none2.is_none()); - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "the second read for a missing slug should hit the cache, not re-query" - ); - Ok(()) - } - - #[tokio::test] - async fn errors_do_not_populate_cache() -> Result<()> { - let cache = QueryCache::new(); - let calls = AtomicUsize::new(0); - - let res = cache - .filter_universe(|| { - calls.fetch_add(1, Ordering::SeqCst); - async { Err::(anyhow!("boom")) } - }) - .await; - assert!(res.is_err(), "error path bubbles up"); - - cache - .filter_universe(|| { - calls.fetch_add(1, Ordering::SeqCst); - async { Ok(empty_universe()) } - }) - .await?; - assert_eq!( - calls.load(Ordering::SeqCst), - 2, - "second call must rerun after an errored first call", - ); - Ok(()) - } -} diff --git a/benchmarks-website/server/src/read_model.rs b/benchmarks-website/server/src/read_model.rs deleted file mode 100644 index b2aa0e21868..00000000000 --- a/benchmarks-website/server/src/read_model.rs +++ /dev/null @@ -1,837 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Materialized read model for the hot benchmark website paths. -//! -//! DuckDB remains the source of truth, but the normal latest-100 website -//! payloads are deterministic between ingests. This module builds those -//! payloads once per database snapshot, stores identity/gzip/brotli bytes in -//! memory, and lets handlers serve bytes directly on the hot path. - -use std::collections::VecDeque; -use std::future::Future; -use std::hash::Hasher as _; -use std::io::Read as _; -use std::io::Write as _; -use std::num::NonZeroU32; -use std::pin::Pin; -use std::sync::Arc; - -use anyhow::Context as _; -use anyhow::Result; -use axum::body::Body; -use axum::http::HeaderMap; -use axum::http::HeaderValue; -use axum::http::StatusCode; -use axum::http::header; -use axum::response::Response; -use bytes::Bytes; -use duckdb::Connection; -use flate2::Compression; -use flate2::write::GzEncoder; -use parking_lot::RwLock; -use serde::Serialize; -use tokio::sync::Mutex as AsyncMutex; -use twox_hash::XxHash64; -use vortex_utils::aliases::hash_map::HashMap; - -use crate::api; -use crate::api::ChartResponse; -use crate::api::CommitWindow; -use crate::api::FilterUniverse; -use crate::api::Group; -use crate::api::GroupChartsResponse; -use crate::api::GroupsResponse; -use crate::api::NamedChartResponse; -use crate::api::Summary; -use crate::db; -use crate::db::DbHandle; -use crate::slug::ChartKey; - -/// Number of charts included in one materialized group shard response. -pub const GROUP_SHARD_CHARTS: usize = 8; - -/// Superseded read generations retained for pages opened before newer ingests -/// completed. Each generation owns precompressed latest-100 artifacts, so keep -/// this finite while covering ordinary CI bursts. -const RETAINED_PREVIOUS_GENERATIONS: usize = 8; - -/// Cache policy for a materialized artifact route. -#[derive(Debug, Clone, Copy)] -pub enum ArtifactCachePolicy { - /// Stable URLs such as `/api/groups`; browsers should revalidate. - Revalidate, - /// Versioned URLs under `/api/artifacts/{generation}/...`. - Immutable, -} - -impl ArtifactCachePolicy { - fn header_value(self) -> &'static str { - match self { - Self::Revalidate => "no-cache, max-age=0, must-revalidate", - Self::Immutable => "public, max-age=31536000, immutable", - } - } -} - -/// A JSON artifact encoded in every representation the server wants to serve. -#[derive(Debug, Clone)] -pub struct EncodedArtifact { - identity: Bytes, - gzip: Bytes, - br: Bytes, - etag: HeaderValue, -} - -impl EncodedArtifact { - fn new(generation_id: &str, identity: Vec) -> Result { - let gzip = gzip_bytes(&identity).context("gzip artifact")?; - let br = brotli_bytes(&identity).context("brotli artifact")?; - let etag = HeaderValue::from_str(&format!("\"{generation_id}\"")) - .context("building artifact ETag")?; - Ok(Self { - identity: Bytes::from(identity), - gzip: Bytes::from(gzip), - br: Bytes::from(br), - etag, - }) - } - - /// Uncompressed bytes, used when an HTML page embeds a single chart. - pub fn identity(&self) -> &Bytes { - &self.identity - } - - /// Build an Axum response using the client's `Accept-Encoding` and - /// `If-None-Match` headers. - pub fn response(&self, request_headers: &HeaderMap, policy: ArtifactCachePolicy) -> Response { - if if_none_match_matches(request_headers, &self.etag) { - return artifact_response_builder(StatusCode::NOT_MODIFIED, policy, &self.etag) - .body(Body::empty()) - .expect("artifact 304 response"); - } - - let (encoding, bytes) = match preferred_encoding(request_headers) { - ArtifactEncoding::Brotli => (Some("br"), self.br.clone()), - ArtifactEncoding::Gzip => (Some("gzip"), self.gzip.clone()), - ArtifactEncoding::Identity => (None, self.identity.clone()), - }; - - let mut builder = artifact_response_builder(StatusCode::OK, policy, &self.etag) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CONTENT_LENGTH, bytes.len().to_string()); - if let Some(encoding) = encoding { - builder = builder.header(header::CONTENT_ENCODING, encoding); - } - builder.body(Body::from(bytes)).expect("artifact response") - } -} - -fn artifact_response_builder( - status: StatusCode, - policy: ArtifactCachePolicy, - etag: &HeaderValue, -) -> axum::http::response::Builder { - Response::builder() - .status(status) - .header(header::CACHE_CONTROL, policy.header_value()) - .header(header::VARY, "Accept-Encoding") - .header(header::ETAG, etag.clone()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ArtifactEncoding { - Brotli, - Gzip, - Identity, -} - -fn preferred_encoding(headers: &HeaderMap) -> ArtifactEncoding { - let Some(raw) = headers - .get(header::ACCEPT_ENCODING) - .and_then(|v| v.to_str().ok()) - else { - return ArtifactEncoding::Identity; - }; - if accepts_encoding(raw, "br") { - ArtifactEncoding::Brotli - } else if accepts_encoding(raw, "gzip") { - ArtifactEncoding::Gzip - } else { - ArtifactEncoding::Identity - } -} - -fn accepts_encoding(raw: &str, expected: &str) -> bool { - raw.split(',').any(|part| { - let mut pieces = part.trim().split(';'); - let name = pieces.next().unwrap_or_default().trim(); - if !name.eq_ignore_ascii_case(expected) { - return false; - } - pieces.all(|piece| { - let piece = piece.trim(); - let Some((key, value)) = piece.split_once('=') else { - return true; - }; - if !key.trim().eq_ignore_ascii_case("q") { - return true; - } - // Per RFC 9110 the q value is a positive number in [0, 1]; we - // reject `q=0` (client refuses the encoding) AND reject - // malformed q-values (`q=foo`, `q=`, `q=inf`, `q=NaN`, `q=2`). - // The earlier `map_or(true, |q| q > 0.0)` treated every parse - // failure as accept, which is non-conformant in the - // opposite direction. - match value.trim().parse::() { - Ok(q) if q.is_finite() && (0.0..=1.0).contains(&q) => q > 0.0, - _ => false, - } - }) - }) -} - -fn if_none_match_matches(headers: &HeaderMap, etag: &HeaderValue) -> bool { - let Some(raw) = headers - .get(header::IF_NONE_MATCH) - .and_then(|v| v.to_str().ok()) - else { - return false; - }; - let etag = etag.to_str().unwrap_or_default(); - raw.split(',').any(|candidate| { - let candidate = candidate.trim(); - candidate == "*" || candidate == etag - }) -} - -fn gzip_bytes(bytes: &[u8]) -> Result> { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(bytes)?; - Ok(encoder.finish()?) -} - -fn brotli_bytes(bytes: &[u8]) -> Result> { - let mut reader = brotli::CompressorReader::new(bytes, 4096, 5, 22); - let mut out = Vec::new(); - reader.read_to_end(&mut out)?; - Ok(out) -} - -type RebuildFuture = Pin> + Send>>; -type RebuildTask = Arc RebuildFuture + Send + Sync>; - -/// Shared in-memory store for the active and recently superseded read -/// generations. -#[derive(Clone)] -pub struct ReadStore { - inner: Arc>, - rebuild: Arc>, -} - -struct ReadStoreInner { - active: Arc, - previous: VecDeque>, -} - -#[derive(Default)] -struct RebuildState { - running: bool, - pending: bool, -} - -impl ReadStore { - /// Build the first generation synchronously during startup. Failure here - /// fails server startup rather than pushing a cold build onto users. - pub fn build_initial(db: &DbHandle) -> Result { - let mut conn = db.connection()?; - let generation = Arc::new(build_generation(&mut conn)?); - Ok(Self { - inner: Arc::new(RwLock::new(ReadStoreInner { - active: generation, - previous: VecDeque::new(), - })), - rebuild: Arc::new(AsyncMutex::new(RebuildState::default())), - }) - } - - /// Current generation. - pub fn active(&self) -> Arc { - Arc::clone(&self.inner.read().active) - } - - /// Find the active or retained previous generation by id. - pub fn generation(&self, id: &str) -> Option> { - let inner = self.inner.read(); - if inner.active.id == id { - return Some(Arc::clone(&inner.active)); - } - inner - .previous - .iter() - .find(|generation| generation.id == id) - .map(Arc::clone) - } - - /// Schedule a background rebuild after ingest. The active generation is - /// retained until the rebuild succeeds, and repeated ingests coalesce into - /// at most one follow-up rebuild. - pub async fn schedule_rebuild(&self, db: DbHandle) { - let build: RebuildTask = Arc::new(move || { - let db = db.clone(); - Box::pin(async move { db::run_read_blocking(&db, build_generation).await }) - }); - self.schedule_rebuild_with(build).await; - } - - async fn schedule_rebuild_with(&self, build: RebuildTask) { - let mut state = self.rebuild.lock().await; - if state.running { - state.pending = true; - return; - } - state.running = true; - let store = self.clone(); - tokio::spawn(async move { - store.rebuild_loop(build).await; - }); - } - - async fn rebuild_loop(self, build: RebuildTask) { - loop { - match build().await { - Ok(generation) => self.install(generation), - Err(err) => { - tracing::error!(error = ?err, "read model rebuild failed"); - } - } - - let mut state = self.rebuild.lock().await; - if state.pending { - state.pending = false; - continue; - } - state.running = false; - break; - } - } - - fn install(&self, generation: ReadGeneration) { - let mut inner = self.inner.write(); - let previous = Arc::clone(&inner.active); - inner.active = Arc::new(generation); - inner.previous.push_front(previous); - while inner.previous.len() > RETAINED_PREVIOUS_GENERATIONS { - inner.previous.pop_back(); - } - } -} - -/// One immutable read snapshot. -pub struct ReadGeneration { - id: String, - groups: Arc>, - filter_universe: Arc, - groups_artifact: EncodedArtifact, - chart_artifacts: HashMap, - group_artifacts: HashMap, - group_shards: HashMap, - group_shard_counts: HashMap, - chart_payloads: HashMap>, -} - -impl ReadGeneration { - /// Content-derived generation id. - pub fn id(&self) -> &str { - &self.id - } - - /// Structured groups for HTML rendering. - pub fn groups(&self) -> Arc> { - Arc::clone(&self.groups) - } - - /// Structured filter universe for HTML rendering. - pub fn filter_universe(&self) -> Arc { - Arc::clone(&self.filter_universe) - } - - /// Materialized `/api/groups` body. - pub fn groups_artifact(&self) -> &EncodedArtifact { - &self.groups_artifact - } - - /// Materialized latest-100 `/api/chart/{slug}` body. - pub fn chart_artifact(&self, slug: &str) -> Option<&EncodedArtifact> { - self.chart_artifacts.get(slug) - } - - /// Materialized latest-100 `/api/group/{slug}` compatibility body. - pub fn group_artifact(&self, slug: &str) -> Option<&EncodedArtifact> { - self.group_artifacts.get(slug) - } - - /// Materialized latest-100 shard body for landing/group hydration. - pub fn group_shard_artifact(&self, slug: &str, index: usize) -> Option<&EncodedArtifact> { - self.group_shards.get(&GroupShardKey { - slug: slug.to_string(), - index, - }) - } - - /// Number of materialized shards for a group. - pub fn group_shard_count(&self, slug: &str) -> usize { - self.group_shard_counts.get(slug).copied().unwrap_or(0) - } - - /// Structured latest-100 chart payload for single-chart HTML rendering. - pub fn chart_payload(&self, slug: &str) -> Option> { - self.chart_payloads.get(slug).map(Arc::clone) - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -struct GroupShardKey { - slug: String, - index: usize, -} - -#[derive(Serialize)] -struct GroupShardResponse { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - window: u32, - shard_index: usize, - shard_count: usize, - charts: Vec, -} - -struct RawArtifact { - key: String, - kind: RawArtifactKind, - bytes: Vec, -} - -enum RawArtifactKind { - Groups, - Chart { slug: String }, - Group { slug: String }, - GroupShard { slug: String, index: usize }, -} - -fn build_generation(conn: &mut Connection) -> Result { - api::read_transaction(conn, build_generation_from_snapshot) -} - -fn build_generation_from_snapshot(conn: &Connection) -> Result { - let groups = Arc::new(api::collect_groups(conn)?); - let filter_universe = Arc::new(api::collect_filter_universe(conn)?); - let window = CommitWindow::Last( - NonZeroU32::new(api::DEFAULT_COMMIT_WINDOW).expect("default window is non-zero"), - ); - - let mut raw = Vec::new(); - raw.push(RawArtifact { - key: "api:groups".to_string(), - kind: RawArtifactKind::Groups, - bytes: serde_json::to_vec(&GroupsResponse { - groups: Arc::clone(&groups), - }) - .context("serialize groups artifact")?, - }); - - let mut chart_payloads = HashMap::new(); - let mut group_shard_counts = HashMap::new(); - - for group in groups.iter() { - let mut charts = Vec::with_capacity(group.charts.len()); - for link in &group.charts { - let chart = if let Some(chart) = chart_payloads.get(&link.slug) { - Arc::clone(chart) - } else { - let key = ChartKey::from_slug(&link.slug) - .with_context(|| format!("invalid chart slug in group: {}", link.slug))?; - let Some(chart) = api::chart_payload(conn, &key, &window)? else { - continue; - }; - let chart = Arc::new(chart); - raw.push(RawArtifact { - key: format!("api:chart:{}:100", link.slug), - kind: RawArtifactKind::Chart { - slug: link.slug.clone(), - }, - bytes: serde_json::to_vec(chart.as_ref()) - .with_context(|| format!("serialize chart artifact {}", link.slug))?, - }); - chart_payloads.insert(link.slug.clone(), Arc::clone(&chart)); - chart - }; - charts.push(NamedChartResponse { - name: link.name.clone(), - slug: link.slug.clone(), - chart, - }); - } - - if charts.is_empty() { - group_shard_counts.insert(group.slug.clone(), 0); - continue; - } - - let group_response = GroupChartsResponse { - name: group.name.clone(), - summary: group.summary.clone(), - description: group.description.clone(), - charts: charts.clone(), - }; - raw.push(RawArtifact { - key: format!("api:group:{}:100", group.slug), - kind: RawArtifactKind::Group { - slug: group.slug.clone(), - }, - bytes: serde_json::to_vec(&group_response) - .with_context(|| format!("serialize group artifact {}", group.slug))?, - }); - - let shard_count = charts.len().div_ceil(GROUP_SHARD_CHARTS); - group_shard_counts.insert(group.slug.clone(), shard_count); - for (shard_index, chunk) in charts.chunks(GROUP_SHARD_CHARTS).enumerate() { - let shard = GroupShardResponse { - name: group.name.clone(), - summary: group.summary.clone(), - description: group.description.clone(), - window: api::DEFAULT_COMMIT_WINDOW, - shard_index, - shard_count, - charts: chunk.to_vec(), - }; - raw.push(RawArtifact { - key: format!("api:group-shard:{}:{shard_index}:100", group.slug), - kind: RawArtifactKind::GroupShard { - slug: group.slug.clone(), - index: shard_index, - }, - bytes: serde_json::to_vec(&shard).with_context(|| { - format!( - "serialize group shard artifact {}#{shard_index}", - group.slug - ) - })?, - }); - } - } - - let id = generation_id(&raw); - let mut groups_artifact = None; - let mut chart_artifacts = HashMap::new(); - let mut group_artifacts = HashMap::new(); - let mut group_shards = HashMap::new(); - - for artifact in raw { - let encoded = EncodedArtifact::new(&id, artifact.bytes) - .with_context(|| format!("encode artifact {}", artifact.key))?; - match artifact.kind { - RawArtifactKind::Groups => groups_artifact = Some(encoded), - RawArtifactKind::Chart { slug } => { - chart_artifacts.insert(slug, encoded); - } - RawArtifactKind::Group { slug } => { - group_artifacts.insert(slug, encoded); - } - RawArtifactKind::GroupShard { slug, index } => { - group_shards.insert(GroupShardKey { slug, index }, encoded); - } - } - } - - Ok(ReadGeneration { - id, - groups, - filter_universe, - groups_artifact: groups_artifact.context("groups artifact missing")?, - chart_artifacts, - group_artifacts, - group_shards, - group_shard_counts, - chart_payloads, - }) -} - -fn generation_id(raw: &[RawArtifact]) -> String { - let mut sorted: Vec<_> = raw.iter().collect(); - sorted.sort_by(|a, b| a.key.cmp(&b.key)); - let mut hash = XxHash64::with_seed(0); - for artifact in sorted { - hash.write_u64(artifact.key.len() as u64); - hash.write(artifact.key.as_bytes()); - hash.write_u64(artifact.bytes.len() as u64); - hash.write(&artifact.bytes); - } - format!("{:016x}", hash.finish()) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - use std::time::Duration; - - use axum::http::header; - use tokio::sync::Notify; - use tokio::time::sleep; - - use super::*; - - fn raw_artifact(key: &str, bytes: &[u8]) -> RawArtifact { - RawArtifact { - key: key.to_string(), - kind: RawArtifactKind::Groups, - bytes: bytes.to_vec(), - } - } - - fn empty_generation(id: &str) -> Result { - Ok(ReadGeneration { - id: id.to_string(), - groups: Arc::new(Vec::new()), - filter_universe: Arc::new(FilterUniverse::default()), - groups_artifact: EncodedArtifact::new(id, br#"{"groups":[]}"#.to_vec())?, - chart_artifacts: HashMap::new(), - group_artifacts: HashMap::new(), - group_shards: HashMap::new(), - group_shard_counts: HashMap::new(), - chart_payloads: HashMap::new(), - }) - } - - fn test_store(id: &str) -> Result { - Ok(ReadStore { - inner: Arc::new(RwLock::new(ReadStoreInner { - active: Arc::new(empty_generation(id)?), - previous: VecDeque::new(), - })), - rebuild: Arc::new(AsyncMutex::new(RebuildState::default())), - }) - } - - async fn wait_for_rebuild_idle(store: &ReadStore) { - for _ in 0..100 { - if !store.rebuild.lock().await.running { - return; - } - sleep(Duration::from_millis(10)).await; - } - panic!("read model rebuild did not become idle"); - } - - async fn wait_for_calls(calls: &AtomicUsize, expected: usize) { - for _ in 0..100 { - if calls.load(Ordering::SeqCst) >= expected { - return; - } - sleep(Duration::from_millis(10)).await; - } - panic!("read model rebuild did not start"); - } - - #[test] - fn generation_ids_are_content_derived_and_order_stable() { - let a = vec![raw_artifact("b", b"two"), raw_artifact("a", b"one")]; - let b = vec![raw_artifact("a", b"one"), raw_artifact("b", b"two")]; - let c = vec![raw_artifact("a", b"one"), raw_artifact("b", b"changed")]; - - assert_eq!(generation_id(&a), generation_id(&b)); - assert_ne!(generation_id(&a), generation_id(&c)); - } - - #[test] - fn encoded_artifact_negotiates_precompressed_variants() -> Result<()> { - let artifact = EncodedArtifact::new("abc123", br#"{"ok":true}"#.to_vec())?; - let mut headers = HeaderMap::new(); - headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); - - let resp = artifact.response(&headers, ArtifactCachePolicy::Immutable); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!( - resp.headers() - .get(header::CONTENT_ENCODING) - .and_then(|v| v.to_str().ok()), - Some("gzip") - ); - assert_eq!( - resp.headers() - .get(header::VARY) - .and_then(|v| v.to_str().ok()), - Some("Accept-Encoding") - ); - assert!( - resp.headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .is_some_and(|v| v.contains("immutable")) - ); - Ok(()) - } - - #[test] - fn encoded_artifact_honors_nonzero_q_values() -> Result<()> { - let artifact = EncodedArtifact::new("abc123", br#"{"ok":true}"#.to_vec())?; - let mut headers = HeaderMap::new(); - headers.insert( - header::ACCEPT_ENCODING, - HeaderValue::from_static("br;q=0.8, gzip;q=0.5"), - ); - - let resp = artifact.response(&headers, ArtifactCachePolicy::Immutable); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!( - resp.headers() - .get(header::CONTENT_ENCODING) - .and_then(|v| v.to_str().ok()), - Some("br") - ); - Ok(()) - } - - #[test] - fn encoded_artifact_rejects_zero_q_values() -> Result<()> { - let artifact = EncodedArtifact::new("abc123", br#"{"ok":true}"#.to_vec())?; - let mut headers = HeaderMap::new(); - headers.insert( - header::ACCEPT_ENCODING, - HeaderValue::from_static("br;q=0, gzip;q=0"), - ); - - let resp = artifact.response(&headers, ArtifactCachePolicy::Immutable); - assert_eq!(resp.status(), StatusCode::OK); - assert!( - resp.headers().get(header::CONTENT_ENCODING).is_none(), - "q=0 encodings should fall back to identity" - ); - Ok(()) - } - - /// `accepts_encoding` rejects malformed q-values (parse failure, out - /// of range, non-finite). Previously the parser treated every - /// non-numeric q-value as `true` (accept), which was non-conformant - /// in the opposite direction from the original q=0 bug. - #[test] - fn accepts_encoding_rejects_malformed_q_values() { - // Malformed numeric inputs: not a number, empty, out of range, - // non-finite. Each should reject the encoding. - for raw in [ - "gzip;q=foo", - "gzip;q=", - "gzip;q=2", - "gzip;q=1.5", - "gzip;q=-0.1", - "gzip;q=NaN", - "gzip;q=inf", - ] { - assert!( - !accepts_encoding(raw, "gzip"), - "should reject malformed q-value: {raw:?}" - ); - } - // Valid edge q-values still accepted. - for raw in ["gzip", "gzip;q=1", "gzip;q=1.0", "gzip;q=0.001"] { - assert!( - accepts_encoding(raw, "gzip"), - "should accept valid q-value: {raw:?}" - ); - } - } - - #[test] - fn encoded_artifact_returns_304_for_matching_etag() -> Result<()> { - let artifact = EncodedArtifact::new("abc123", br#"{"ok":true}"#.to_vec())?; - let mut headers = HeaderMap::new(); - headers.insert( - header::IF_NONE_MATCH, - HeaderValue::from_static("\"abc123\""), - ); - - let resp = artifact.response(&headers, ArtifactCachePolicy::Revalidate); - assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); - assert_eq!( - resp.headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()), - Some("\"abc123\"") - ); - Ok(()) - } - - #[tokio::test] - async fn failed_rebuild_keeps_old_generation_active() -> Result<()> { - let store = test_store("old")?; - let build: RebuildTask = Arc::new(|| Box::pin(async { anyhow::bail!("boom") })); - - store.schedule_rebuild_with(build).await; - wait_for_rebuild_idle(&store).await; - - assert_eq!(store.active().id(), "old"); - assert!(store.generation("old").is_some()); - Ok(()) - } - - #[tokio::test] - async fn install_evicts_only_beyond_retained_generation_limit() -> Result<()> { - let store = test_store("gen0")?; - for idx in 1..=(RETAINED_PREVIOUS_GENERATIONS + 2) { - store.install(empty_generation(&format!("gen{idx}"))?); - } - - assert_eq!( - store.active().id(), - &format!("gen{}", RETAINED_PREVIOUS_GENERATIONS + 2) - ); - assert!( - store.generation("gen2").is_some(), - "oldest retained generation should stay addressable" - ); - assert!( - store.generation("gen1").is_none(), - "generations beyond the retention window should be evicted" - ); - Ok(()) - } - - #[tokio::test] - async fn concurrent_rebuild_requests_coalesce() -> Result<()> { - let store = test_store("old")?; - let calls = Arc::new(AtomicUsize::new(0)); - let release_first = Arc::new(Notify::new()); - let build: RebuildTask = Arc::new({ - let calls = Arc::clone(&calls); - let release_first = Arc::clone(&release_first); - move || { - let calls = Arc::clone(&calls); - let release_first = Arc::clone(&release_first); - Box::pin(async move { - let call = calls.fetch_add(1, Ordering::SeqCst) + 1; - if call == 1 { - release_first.notified().await; - } - empty_generation(&format!("gen{call}")) - }) - } - }); - - store.schedule_rebuild_with(Arc::clone(&build)).await; - wait_for_calls(&calls, 1).await; - store.schedule_rebuild_with(Arc::clone(&build)).await; - store.schedule_rebuild_with(build).await; - release_first.notify_one(); - wait_for_rebuild_idle(&store).await; - - assert_eq!(calls.load(Ordering::SeqCst), 2); - assert_eq!(store.active().id(), "gen2"); - assert!(store.generation("gen1").is_some()); - assert!(store.generation("old").is_some()); - Ok(()) - } -} diff --git a/benchmarks-website/server/src/records.rs b/benchmarks-website/server/src/records.rs deleted file mode 100644 index 446b701e296..00000000000 --- a/benchmarks-website/server/src/records.rs +++ /dev/null @@ -1,291 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Wire shapes for `POST /api/ingest`. -//! -//! Each [`Record`] variant deserializes one row destined for one of the five -//! fact tables in [`crate::schema`]. The producer side of the contract lives -//! in `vortex-bench/src/v3.rs` (the `--gh-json-v3` emitter); when changing a -//! shape here, change both sides in the same commit. -//! -//! ## Records are discriminated by `kind` -//! -//! Every record carries a `kind` field that picks one of the five fact -//! tables; serde drives this with `#[serde(tag = "kind", rename_all = -//! "snake_case")]`. -//! -//! | `kind` | Destination table | -//! |----------------------|-------------------------| -//! | `query_measurement` | `query_measurements` | -//! | `compression_time` | `compression_times` | -//! | `compression_size` | `compression_sizes` | -//! | `random_access_time` | `random_access_times` | -//! | `vector_search_run` | `vector_search_runs` | -//! -//! Every record struct carries `#[serde(deny_unknown_fields)]`, so unknown -//! fields surface as a `400` with the offending record's index — version -//! skew is supposed to fail loudly. Unknown `kind` values produce the same -//! `400` from the outer enum's tag check. -//! -//! ## Ingest envelope -//! -//! `POST /api/ingest` accepts one [`Envelope`] per request. The envelope -//! wraps a heterogeneous batch of records (any mix of `kind`s): -//! -//! - `run_meta` — [`RunMeta`] with `benchmark_id`, `schema_version` -//! (must equal [`crate::schema::SCHEMA_VERSION`]), and `started_at`. -//! - `commit` — [`CommitInfo`] with the columns of the `commits` dim table, -//! keyed by their column names with `commit_sha` renamed to `sha`. The -//! server upserts this row before applying any record. -//! - `records` — array of per-`kind` records. -//! -//! `vortex-bench --gh-json-v3 ` writes JSONL of bare records only — -//! the envelope (`run_meta` + `commit`) is added by the post-ingest script -//! before POSTing, which keeps the Rust emitter dependency-light and lets -//! CI fill the commit fields from `${{ github.sha }}` plus `git show`. - -use serde::Deserialize; - -/// One ingest payload. -/// -/// `run_meta` and `commit` are added by the post-ingest script around the -/// JSONL of bare records the Rust emitter writes. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Envelope { - /// Per-run metadata, including the wire schema version. - pub run_meta: RunMeta, - /// Commit context — upserted into `commits` before any record is applied. - pub commit: CommitInfo, - /// Heterogeneous batch of fact-table records. - pub records: Vec, -} - -/// Run-level metadata. `schema_version` is checked against -/// [`crate::schema::SCHEMA_VERSION`] before any record is processed. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RunMeta { - /// Free-form ID of the producing run (e.g. `bench.yml@`). - pub benchmark_id: String, - /// Wire schema version. Must equal [`crate::schema::SCHEMA_VERSION`]. - pub schema_version: i32, - /// RFC 3339 timestamp at which the run started. - pub started_at: String, -} - -/// Columns for the `commits` dim table. The wire field for `commit_sha` is -/// renamed to `sha` per the contract; every other field name matches the -/// column name in [`crate::schema`]. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CommitInfo { - /// 40-hex lowercase commit SHA. - pub sha: String, - /// RFC 3339 / ISO 8601 timestamp of the commit. - pub timestamp: String, - /// Full commit message (the server renders only the first line). - pub message: String, - /// Author's display name. - pub author_name: String, - /// Author's email. - pub author_email: String, - /// Committer's display name. - pub committer_name: String, - /// Committer's email. - pub committer_email: String, - /// Git tree SHA the commit points at. - pub tree_sha: String, - /// GitHub URL for the commit (used as the click-through fallback when - /// no `(#NNNN)` tag is present in the message). - pub url: String, -} - -/// A single ingest record, discriminated by `kind`. -#[derive(Debug, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum Record { - /// `query_measurement` → `query_measurements` table. - QueryMeasurement(QueryMeasurement), - /// `compression_time` → `compression_times` table. - CompressionTime(CompressionTime), - /// `compression_size` → `compression_sizes` table. - CompressionSize(CompressionSize), - /// `random_access_time` → `random_access_times` table. - RandomAccessTime(RandomAccessTime), - /// `vector_search_run` → `vector_search_runs` table. - VectorSearchRun(VectorSearchRun), -} - -/// SQL query suite measurement (TPC-H, ClickBench, ...). Lands in -/// `query_measurements`. Field names match the schema columns; per-suite dim -/// values are documented on -/// [`vortex_bench::v3::benchmark_dataset_dims`](../../../vortex-bench/src/v3.rs). -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct QueryMeasurement { - /// 40-hex lowercase SHA of the producing commit. - pub commit_sha: String, - /// Top-level suite (e.g. `tpch`, `clickbench`, `public-bi`). - pub dataset: String, - /// Categorical sub-name (Public-BI dataset; ClickBench flavor). - #[serde(default)] - pub dataset_variant: Option, - /// TPC SF as a string. Populated for TPC-H/TPC-DS, NULL elsewhere. - #[serde(default)] - pub scale_factor: Option, - /// Query index within the suite. The convention (0-based or 1-based) is - /// fixed per suite by the producing bench loop; the migrate classifier - /// matches it by parsing literal digits out of `q07`-style v2 chart - /// names. - pub query_idx: i32, - /// Storage backend the run targeted: `nvme` or `s3`. Validated on insert. - pub storage: String, - /// Engine (`datafusion`, `duckdb`, `vortex`, `arrow`). - pub engine: String, - /// On-disk format (`parquet`, `vortex-file-compressed`, `lance`, ...). - pub format: String, - /// Median per-iteration wall time in nanoseconds. - pub value_ns: i64, - /// Per-iteration wall times in nanoseconds (median of these is `value_ns`). - pub all_runtimes_ns: Vec, - /// Peak resident-set bytes during the query, when memory tracking was on. - #[serde(default)] - pub peak_physical: Option, - /// Peak virtual-memory bytes during the query, when memory tracking was on. - #[serde(default)] - pub peak_virtual: Option, - /// Resident-set delta across the query, when memory tracking was on. - #[serde(default)] - pub physical_delta: Option, - /// Virtual-memory delta across the query, when memory tracking was on. - #[serde(default)] - pub virtual_delta: Option, - /// Host environment triple (e.g. `x86_64-linux-gnu`). - #[serde(default)] - pub env_triple: Option, -} - -/// Encode-or-decode timing from `compress-bench`. Lands in -/// `compression_times`. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CompressionTime { - /// 40-hex lowercase SHA of the producing commit. - pub commit_sha: String, - /// Compression dataset name. - pub dataset: String, - /// Optional dataset variant (reserved; unused at alpha). - #[serde(default)] - pub dataset_variant: Option, - /// On-disk format the timing applies to. - pub format: String, - /// `encode` or `decode`. The server treats it as opaque on the wire. - pub op: String, - /// Median per-iteration wall time in nanoseconds. - pub value_ns: i64, - /// Per-iteration wall times in nanoseconds. - pub all_runtimes_ns: Vec, - /// Host environment triple. - #[serde(default)] - pub env_triple: Option, -} - -/// On-disk size from `compress-bench`. One-shot, no per-iteration data. -/// Lands in `compression_sizes`. Compression ratios (e.g. `vortex/parquet`) -/// are NOT a separate record kind — they are computed at read time from -/// pairs of these rows. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CompressionSize { - /// 40-hex lowercase SHA of the producing commit. - pub commit_sha: String, - /// Compression dataset name. - pub dataset: String, - /// Optional dataset variant (reserved; unused at alpha). - #[serde(default)] - pub dataset_variant: Option, - /// On-disk format the size applies to. - pub format: String, - /// Compressed-file size in bytes. - pub value_bytes: i64, -} - -/// Take-time timing from `random-access-bench`. Lands in -/// `random_access_times`. Datasets here (chimp, taxi, ...) are a different -/// namespace from the SQL query suites' dataset names. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RandomAccessTime { - /// 40-hex lowercase SHA of the producing commit. - pub commit_sha: String, - /// Random-access dataset name. - pub dataset: String, - /// On-disk format the timing applies to. - pub format: String, - /// Median per-iteration wall time in nanoseconds. - pub value_ns: i64, - /// Per-iteration wall times in nanoseconds. - pub all_runtimes_ns: Vec, - /// Host environment triple. - #[serde(default)] - pub env_triple: Option, -} - -/// Cosine-similarity scan from `vector-search-bench`. Lands in -/// `vector_search_runs`. The only family that emits timing **plus** side -/// counters in the same row. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct VectorSearchRun { - /// 40-hex lowercase SHA of the producing commit. - pub commit_sha: String, - /// Vector dataset name (e.g. `cohere-large-10m`). - pub dataset: String, - /// Train-split layout label. - pub layout: String, - /// Compression flavor label. - pub flavor: String, - /// Cosine threshold passed to the scan filter. - pub threshold: f64, - /// Median per-scan wall time in nanoseconds. - pub value_ns: i64, - /// Per-iteration wall times in nanoseconds. - pub all_runtimes_ns: Vec, - /// Number of rows that survived the cosine filter. - pub matches: i64, - /// Total rows scanned. - pub rows_scanned: i64, - /// Total on-disk bytes scanned. - pub bytes_scanned: i64, - /// Number of timed iterations. Not part of the dim hash. - pub iterations: i32, - /// Host environment triple. - #[serde(default)] - pub env_triple: Option, -} - -impl Record { - /// The `commit_sha` referenced by this record. Every record carries one; - /// the server checks the envelope's `commit.sha` matches. - pub fn commit_sha(&self) -> &str { - match self { - Self::QueryMeasurement(r) => &r.commit_sha, - Self::CompressionTime(r) => &r.commit_sha, - Self::CompressionSize(r) => &r.commit_sha, - Self::RandomAccessTime(r) => &r.commit_sha, - Self::VectorSearchRun(r) => &r.commit_sha, - } - } - - /// The wire `kind` string. Useful for logging and error messages. - pub fn kind(&self) -> &'static str { - match self { - Self::QueryMeasurement(_) => "query_measurement", - Self::CompressionTime(_) => "compression_time", - Self::CompressionSize(_) => "compression_size", - Self::RandomAccessTime(_) => "random_access_time", - Self::VectorSearchRun(_) => "vector_search_run", - } - } -} diff --git a/benchmarks-website/server/src/schema.rs b/benchmarks-website/server/src/schema.rs deleted file mode 100644 index c181021142d..00000000000 --- a/benchmarks-website/server/src/schema.rs +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! DuckDB schema applied on server boot - one `commits` dim plus five fact -//! tables, one per measurement family. -//! -//! ## Design principles -//! -//! 1. **One fact table per (dim shape, value shape).** A row in any fact -//! table has every value column populated; NULLs only appear in genuinely -//! optional dimensions. The five families have different dim shapes, so -//! forcing them into one wide table either bloats every row with NULL -//! columns or splits a single scan's results across multiple rows that -//! have to be re-joined to render one chart. -//! 2. **No discriminator columns spanning families.** No `metric_kind` enum -//! forcing the five shapes into one row. -//! 3. **No JSON escape hatch.** New benchmark parameters become real columns. -//! Adding a nullable column is cheap; the readability win is worth it. -//! 4. **Hashed primary key per fact table.** Every fact table's -//! `measurement_id` is a deterministic 64-bit hash of `commit_sha` plus -//! that table's dimensional tuple, computed in -//! [`crate::db::measurement_id_query`] et al. Including `commit_sha` -//! makes every (commit, dim) pair a distinct row - that is exactly what -//! the chart pages render as a time series. Re-emission of the same -//! (commit, dim) pair is the upsert case. The hash is **server-internal** -//! and never crosses a process boundary; the wire never carries it. -//! 5. **`commits` is the only dim table.** Engine, format, dataset, etc. -//! stay as inline strings; DuckDB's dictionary encoding makes a lookup -//! table pointless. -//! 6. **Ratios are not stored.** Computed at query time from -//! `compression_sizes`. -//! -//! ## Tables -//! -//! - **`commits`** - dim table. `commit_sha` is the PK. `timestamp`, -//! `tree_sha`, and `url` are required (the server cannot render a chart -//! without them); `message` and the author/committer name + email pair are -//! nullable so v2-imported rows that lacked them survive. Populated on -//! every `/api/ingest` from the envelope's `commit` block, and on every -//! migrator run from `commits.json`. -//! - **`query_measurements`** - SQL query suite measurements (TPC-H, TPC-DS, -//! ClickBench, StatPopGen, PolarSignals, Fineweb, GhArchive, Public-BI). -//! Natural key: `(commit_sha, dataset, dataset_variant, scale_factor, -//! query_idx, storage, engine, format)`. Memory columns -//! (`peak_physical`, `peak_virtual`, `physical_delta`, `virtual_delta`) -//! are populated together when the run was instrumented for memory and -//! are NULL otherwise; the ingest path enforces "all four or none". -//! `dataset_variant` carries a categorical sub-name (Public-BI dataset, -//! ClickBench flavor); `scale_factor` is the TPC SF as a string. -//! - **`compression_times`** - encode/decode timings from `compress-bench`. -//! Natural key: `(commit_sha, dataset, dataset_variant, format, op)`, -//! where `op ∈ {encode, decode}`. Encode and decode share a table because -//! they share dim and value shape; keeping them together makes the -//! per-format chart a single SQL query. -//! - **`compression_sizes`** - on-disk sizes from `compress-bench`. One-shot -//! (no per-iteration data, no `all_runtimes_ns`). Natural key: -//! `(commit_sha, dataset, dataset_variant, format)`. Compression ratios -//! (e.g. `vortex:parquet-zstd`) are NOT stored - they are a SELECT over -//! this table joined to itself, computed in `api/summary.rs`. -//! - **`random_access_times`** - take-time timings from -//! `random-access-bench`. Different dataset namespace from -//! `compression_times` (chimp, taxi, etc.) - kept in its own table so -//! dataset filters never have to disambiguate which suite a row belongs -//! to. Natural key: `(commit_sha, dataset, format)`. -//! - **`vector_search_runs`** - cosine-similarity scans from -//! `vector-search-bench`. The only family that emits a timing **plus** -//! side counters (`matches`, `rows_scanned`, `bytes_scanned`) for the -//! same scan; keeping them in one row avoids a 1:N split that has to be -//! re-joined on read. Natural key: `(commit_sha, dataset, layout, -//! flavor, threshold)`. `iterations` is not part of the dim hash - it is -//! a side count, like `matches`. -//! -//! ## Column conventions -//! -//! - `commit_sha` is `TEXT NOT NULL` on every fact table and references the -//! `commits.commit_sha` PK. There is no FK constraint declared at alpha; -//! the ingest path upserts the commit before the records. -//! - `value_ns` is the median per-iteration nanosecond timing for timing -//! tables. `value_bytes` is the on-disk byte count for `compression_sizes`. -//! - `all_runtimes_ns BIGINT[]` carries the per-iteration timings inline. -//! DuckDB's list type avoids a child table; chart code only ever reads -//! `value_ns`, so the list is effectively cold storage today, kept for -//! future variance or distribution charts. -//! - `storage` (only on `query_measurements`) is `nvme` or `s3`. Legacy `gcs` -//! was dropped during the v3 design pass. -//! - `env_triple` is the `arch-os-env` host triple captured at run time -//! (e.g. `x86_64-linux-gnu`). Optional everywhere; useful for slicing -//! results by host class once the data set has more than one host class. -//! -//! ## Schema changes -//! -//! There is no migration framework. If you change the schema: -//! -//! 1. Update the per-family DDL constant ([`COMMITS_DDL`] for the dim, -//! [`QUERY_MEASUREMENTS_DDL`] / [`COMPRESSION_TIMES_DDL`] / -//! [`COMPRESSION_SIZES_DDL`] / [`RANDOM_ACCESS_TIMES_DDL`] / -//! [`VECTOR_SEARCH_RUNS_DDL`] for the facts), the matching -//! [`crate::records`] struct, and the [`crate::family::Family`] entry -//! that ties them together. -//! 2. Update or delete any local `bench.duckdb` (the migrator's -//! `open_target_db` already deletes-and-recreates). -//! 3. Bump [`SCHEMA_VERSION`] if the wire envelope's -//! `run_meta.schema_version` semantics change. -//! -//! A real forward-only migration framework is post-cutover work. - -/// DDL for the `commits` dim table. The five fact-table DDLs live with -/// their respective [`crate::family::Family`] declarations; [`crate::db::open`] -/// applies this constant first, then iterates [`crate::family::FAMILIES`]. -pub const COMMITS_DDL: &str = r#" -CREATE TABLE IF NOT EXISTS commits ( - commit_sha TEXT PRIMARY KEY NOT NULL, - timestamp TIMESTAMPTZ NOT NULL, - message TEXT, - author_name TEXT, - author_email TEXT, - committer_name TEXT, - committer_email TEXT, - tree_sha TEXT NOT NULL, - url TEXT NOT NULL -); -"#; - -/// DDL for the `query_measurements` fact table. Wired into the -/// `schema_ddl` field of [`crate::family::QUERY_MEASUREMENTS`]. -pub const QUERY_MEASUREMENTS_DDL: &str = r#" -CREATE TABLE IF NOT EXISTS query_measurements ( - measurement_id BIGINT PRIMARY KEY NOT NULL, - commit_sha TEXT NOT NULL, - dataset TEXT NOT NULL, - dataset_variant TEXT, - scale_factor TEXT, - query_idx INTEGER NOT NULL, - storage TEXT NOT NULL, - engine TEXT NOT NULL, - format TEXT NOT NULL, - value_ns BIGINT NOT NULL, - all_runtimes_ns BIGINT[] NOT NULL, - peak_physical BIGINT, - peak_virtual BIGINT, - physical_delta BIGINT, - virtual_delta BIGINT, - env_triple TEXT -); -"#; - -/// DDL for the `compression_times` fact table. Wired into the -/// `schema_ddl` field of [`crate::family::COMPRESSION_TIMES`]. -pub const COMPRESSION_TIMES_DDL: &str = r#" -CREATE TABLE IF NOT EXISTS compression_times ( - measurement_id BIGINT PRIMARY KEY NOT NULL, - commit_sha TEXT NOT NULL, - dataset TEXT NOT NULL, - dataset_variant TEXT, - format TEXT NOT NULL, - op TEXT NOT NULL, - value_ns BIGINT NOT NULL, - all_runtimes_ns BIGINT[] NOT NULL, - env_triple TEXT -); -"#; - -/// DDL for the `compression_sizes` fact table. Wired into the -/// `schema_ddl` field of [`crate::family::COMPRESSION_SIZES`]. -pub const COMPRESSION_SIZES_DDL: &str = r#" -CREATE TABLE IF NOT EXISTS compression_sizes ( - measurement_id BIGINT PRIMARY KEY NOT NULL, - commit_sha TEXT NOT NULL, - dataset TEXT NOT NULL, - dataset_variant TEXT, - format TEXT NOT NULL, - value_bytes BIGINT NOT NULL -); -"#; - -/// DDL for the `random_access_times` fact table. Wired into the -/// `schema_ddl` field of [`crate::family::RANDOM_ACCESS_TIMES`]. -pub const RANDOM_ACCESS_TIMES_DDL: &str = r#" -CREATE TABLE IF NOT EXISTS random_access_times ( - measurement_id BIGINT PRIMARY KEY NOT NULL, - commit_sha TEXT NOT NULL, - dataset TEXT NOT NULL, - format TEXT NOT NULL, - value_ns BIGINT NOT NULL, - all_runtimes_ns BIGINT[] NOT NULL, - env_triple TEXT -); -"#; - -/// DDL for the `vector_search_runs` fact table. Wired into the -/// `schema_ddl` field of [`crate::family::VECTOR_SEARCH_RUNS`]. -pub const VECTOR_SEARCH_RUNS_DDL: &str = r#" -CREATE TABLE IF NOT EXISTS vector_search_runs ( - measurement_id BIGINT PRIMARY KEY NOT NULL, - commit_sha TEXT NOT NULL, - dataset TEXT NOT NULL, - layout TEXT NOT NULL, - flavor TEXT NOT NULL, - threshold DOUBLE NOT NULL, - value_ns BIGINT NOT NULL, - all_runtimes_ns BIGINT[] NOT NULL, - matches BIGINT NOT NULL, - rows_scanned BIGINT NOT NULL, - bytes_scanned BIGINT NOT NULL, - iterations INTEGER NOT NULL, - env_triple TEXT -); -"#; - -/// Schema version expected by the server. The ingest envelope's -/// `run_meta.schema_version` must match this exactly at alpha. -/// -/// Coupled sites that MUST agree on this value (see -/// `benchmarks-website/AGENTS.md` → "Wire shapes are a coordinated change"): -/// -/// - This constant. -/// - The producer-side wire-shape source of truth in -/// [`vortex_bench::v3`](../../../vortex-bench/src/v3.rs). -/// - The CI ingest wrapper at `scripts/post-ingest.py`, which fills the -/// envelope's `run_meta.schema_version` from a hardcoded Python constant. -/// Bumping `SCHEMA_VERSION` without bumping `post-ingest.py` makes every -/// CI run 400 at ingest until the script is updated. -pub const SCHEMA_VERSION: i32 = 1; - -/// Every table in the schema, in the order a fresh boot creates them. -/// Used by the snapshot endpoint to drive a per-table `COPY ... TO` -/// across the whole DB and by the restore docs to document the same -/// list. `commits` is the dim table; the rest are facts, derived from -/// the registry so adding a new fact table is one entry in -/// [`crate::family::FAMILIES`] and the snapshot endpoint + restore docs -/// pick it up automatically. -pub static TABLES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - let mut v: Vec<&'static str> = Vec::with_capacity(1 + crate::family::FAMILIES.len()); - v.push("commits"); - v.extend(crate::family::FAMILIES.iter().map(|f| f.table_name)); - v -}); diff --git a/benchmarks-website/server/src/slug.rs b/benchmarks-website/server/src/slug.rs deleted file mode 100644 index 38bd401752f..00000000000 --- a/benchmarks-website/server/src/slug.rs +++ /dev/null @@ -1,222 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Opaque slugs for `/api/chart/:slug` and `/api/group/:slug`. -//! -//! The web-ui treats slugs as opaque strings: it receives them from -//! `/api/groups` and feeds them back unchanged. The server is free to -//! choose any format — slugs here are -//! `.`, where `` names the source -//! fact table and the JSON encodes the chart or group key. Round-tripping -//! the slug back gives a strongly-typed [`ChartKey`] or [`GroupKey`]. - -use anyhow::Context as _; -use anyhow::Result; -use anyhow::anyhow; -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use serde::Deserialize; -use serde::Serialize; - -use crate::family; - -// Slug prefixes live on each [`family::Family`] declaration (the -// `chart_slug_prefix` / `group_slug_prefix` fields). `ChartKey::prefix` -// and `GroupKey::prefix` consult the registry rather than maintaining a -// parallel const table here; the prior `PREFIX_QUERY = "qm"` etc. -// constants were a second source of truth that a test had to assert -// equal-to-the-Family-entries. - -/// The strongly-typed chart key parsed from a slug. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "k")] -pub enum ChartKey { - /// `query_measurements` chart: `(dataset, query_idx)` is the chart key - /// per [`crate::schema`]. Group context (`dataset_variant`, - /// `scale_factor`, `storage`) is carried alongside so the slug fully - /// specifies the chart. - QueryMeasurement { - dataset: String, - dataset_variant: Option, - scale_factor: Option, - storage: String, - query_idx: i32, - }, - /// `compression_times` chart: `(dataset, dataset_variant)`. - CompressionTime { - dataset: String, - dataset_variant: Option, - }, - /// `compression_sizes` chart: `(dataset, dataset_variant)`. - CompressionSize { - dataset: String, - dataset_variant: Option, - }, - /// `random_access_times` chart: `dataset`. - RandomAccess { dataset: String }, - /// `vector_search_runs` chart: `(dataset, layout, threshold)`. - VectorSearch { - dataset: String, - layout: String, - threshold: f64, - }, -} - -impl ChartKey { - fn prefix(&self) -> &'static str { - family::family_for_chart_key(self).chart_slug_prefix - } - - /// Render the slug for this chart key. - pub fn to_slug(&self) -> String { - let json = serde_json::to_vec(self).expect("ChartKey is always JSON-serializable"); - format!("{}.{}", self.prefix(), URL_SAFE_NO_PAD.encode(json)) - } - - /// Parse a slug previously produced by [`Self::to_slug`]. - pub fn from_slug(slug: &str) -> Result { - let (_, encoded) = slug - .split_once('.') - .ok_or_else(|| anyhow!("slug missing '.' separator"))?; - let json = URL_SAFE_NO_PAD - .decode(encoded.as_bytes()) - .context("slug payload was not valid base64url")?; - let key: Self = serde_json::from_slice(&json).context("slug payload was not valid JSON")?; - Ok(key) - } -} - -/// Slug for a *group* of charts. Mirrors [`ChartKey`] but at the group -/// granularity: a group is a set of charts that share every dimension except -/// one (e.g. all 22 TPC-H queries at sf=1 on nvme). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "k")] -pub enum GroupKey { - /// All `query_measurements` charts at one `(dataset, dataset_variant, - /// scale_factor, storage)`. Charts inside vary by `query_idx`. - QueryGroup { - dataset: String, - dataset_variant: Option, - scale_factor: Option, - storage: String, - }, - /// Every compression-time chart (one per `(dataset, dataset_variant)`). - CompressionTimeGroup, - /// Every compression-size chart. - CompressionSizeGroup, - /// Every random-access chart. - RandomAccessGroup, - /// All vector-search charts at one `(dataset, layout)`. Charts inside - /// vary by `threshold`. - VectorSearchGroup { dataset: String, layout: String }, -} - -impl GroupKey { - fn prefix(&self) -> &'static str { - family::family_for_group_key(self).group_slug_prefix - } - - /// Render the slug for this group key. - pub fn to_slug(&self) -> String { - let json = serde_json::to_vec(self).expect("GroupKey is always JSON-serializable"); - format!("{}.{}", self.prefix(), URL_SAFE_NO_PAD.encode(json)) - } - - /// Parse a slug previously produced by [`Self::to_slug`]. - pub fn from_slug(slug: &str) -> Result { - let (_, encoded) = slug - .split_once('.') - .ok_or_else(|| anyhow!("slug missing '.' separator"))?; - let json = URL_SAFE_NO_PAD - .decode(encoded.as_bytes()) - .context("slug payload was not valid base64url")?; - let key: Self = serde_json::from_slice(&json).context("slug payload was not valid JSON")?; - Ok(key) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(key: ChartKey) { - let slug = key.to_slug(); - let parsed = ChartKey::from_slug(&slug).expect("parses back"); - assert_eq!(parsed, key); - } - - #[test] - fn query_measurement_roundtrips() { - roundtrip(ChartKey::QueryMeasurement { - dataset: "tpch".into(), - dataset_variant: None, - scale_factor: Some("1".into()), - storage: "nvme".into(), - query_idx: 7, - }); - } - - #[test] - fn vector_search_roundtrips() { - roundtrip(ChartKey::VectorSearch { - dataset: "cohere-large-10m".into(), - layout: "partitioned".into(), - threshold: 0.75, - }); - } - - #[test] - fn random_access_roundtrips() { - roundtrip(ChartKey::RandomAccess { - dataset: "taxi".into(), - }); - } - - #[test] - fn malformed_slug_rejected() { - assert!(ChartKey::from_slug("not-a-slug").is_err()); - assert!(ChartKey::from_slug("qm.****").is_err()); - } - - fn roundtrip_group(key: GroupKey) { - let slug = key.to_slug(); - let parsed = GroupKey::from_slug(&slug).expect("parses back"); - assert_eq!(parsed, key); - } - - #[test] - fn group_keys_roundtrip() { - roundtrip_group(GroupKey::QueryGroup { - dataset: "tpch".into(), - dataset_variant: None, - scale_factor: Some("1".into()), - storage: "nvme".into(), - }); - roundtrip_group(GroupKey::CompressionTimeGroup); - roundtrip_group(GroupKey::CompressionSizeGroup); - roundtrip_group(GroupKey::RandomAccessGroup); - roundtrip_group(GroupKey::VectorSearchGroup { - dataset: "cohere".into(), - layout: "partitioned".into(), - }); - } - - #[test] - fn group_slug_prefix_distinct_from_chart() { - let chart = ChartKey::CompressionTime { - dataset: "tpch".into(), - dataset_variant: None, - } - .to_slug(); - let group = GroupKey::CompressionTimeGroup.to_slug(); - let chart_prefix = chart.split_once('.').unwrap().0; - let group_prefix = group.split_once('.').unwrap().0; - assert_ne!(chart_prefix, group_prefix); - } - - #[test] - fn malformed_group_slug_rejected() { - assert!(GroupKey::from_slug("not-a-slug").is_err()); - assert!(GroupKey::from_slug("qmg.****").is_err()); - } -} diff --git a/benchmarks-website/server/static/CHARTJS_PLUGIN_ZOOM_LICENSE.md b/benchmarks-website/server/static/CHARTJS_PLUGIN_ZOOM_LICENSE.md deleted file mode 100644 index 5d0f4288b27..00000000000 --- a/benchmarks-website/server/static/CHARTJS_PLUGIN_ZOOM_LICENSE.md +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2013-2021 chartjs-plugin-zoom contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/benchmarks-website/server/static/CHART_JS_LICENSE.md b/benchmarks-website/server/static/CHART_JS_LICENSE.md deleted file mode 100644 index f216610fd7e..00000000000 --- a/benchmarks-website/server/static/CHART_JS_LICENSE.md +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014-2024 Chart.js Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/benchmarks-website/server/static/chart-init.js b/benchmarks-website/server/static/chart-init.js deleted file mode 100644 index eae6cc57662..00000000000 --- a/benchmarks-website/server/static/chart-init.js +++ /dev/null @@ -1,2595 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -// Hydrate Chart.js charts on /, /chart/:slug, and /group/:slug, plus the -// lazy-fetch-on-toggle behaviour for closed `
` groups. -// -// File map (in source order): -// 1. Constants — throttle delays, fetch knobs, caps. -// 2. Canvas state contract — every `canvas.__bench_*` field. -// 3. Per-card DOM contract — every `data-role` selector. -// 4. Global filter state — engines/formats from the navbar. -// 5. Palette + helpers — colours, formatting, throttle. -// 6. Display unit picker — bytes/time/count formatter switch. -// 7. LTTB — pure largest-triangle downsampler. -// 8. Crosshair plugin — inline Chart.js plugin. -// 9. External tooltip handler — factory that returns a Chart.js -// external tooltip handler. -// 10. Payload + datasets — readInlinePayload, buildDatasets, -// rebuildVisibleAndUpdate. -// 11. Full-history warmup — ensureFullHistory, -// replaceChartPayload, plus the -// slider + downsample-badge sync -// helpers. -// 12. Per-card construction — constructChart. -// 13. Range scrollbar strip — bindRangeStrip + pointer math. -// 14. Per-chart toolbar wiring — bindToolbar, attachWheelPan, -// applyScope, applyY. -// 15. Group hydration — bounded shard fetch queue + UI -// helpers. -// 16. Global filter wiring — chip toggle, URL sync, bindings. -// 17. Per-group toolbar wiring — group-level filter + Y override. -// 18. Header controls — theme toggle, expand/collapse all. -// 19. Page wiring — IntersectionObserver, init. -// -// Per-chart UX (for orientation): -// - Each `.chart-card` carries `data-chart-slug`. The card *owns* its own -// toolbar (`.toolbar--card`) — there is no page-level toolbar. -// - Landing groups fetch materialized latest-100 group shards, with bounded -// concurrency. Opening a group then queues `?n=all` for that group's charts -// so the first paint is fast but full history is already warming. -// - `rebuildVisibleAndUpdate` is the single source of truth for the -// rendered point count. The cap is one constant: at most -// `MAX_VISIBLE_POINTS` *unique commit indices* (x-positions) are -// rendered, **shared across every series**. Below the cap we render -// every commit that has data; above it we LTTB the per-commit -// "max-y across series" to pick that many representatives, then -// every series renders at those shared indices. This is what the -// cap is *supposed* to mean: visually, the chart never has more -// than that many x-axis columns regardless of how many lines are -// on it. (Earlier per-series LTTB picked different peaks for each -// series and the union of x-positions blew past the cap.) -// - The slider is throttled to ~16ms (one frame at 60fps) per v2's -// `CONFIG.ZOOM_THROTTLE_DELAY` so dragging the slider feels continuous. -// - Mouse wheel pans horizontally (chartjs-plugin-zoom does not expose -// pan-on-wheel, so a manual `wheel` listener calls `chart.pan(...)`). -// - Drag-pan + drag-rectangle-zoom are wired through the plugin and -// trigger the same `rebuildVisibleAndUpdate` via `onPan`/`onZoom`. -// - A custom inline plugin draws a vertical crosshair at the hovered -// commit; the external tooltip is offset and `pointer-events: none` -// to fix the flicker described in the per-chart UX rebuild brief. -// -// Canvas state contract — every per-chart property we plant on the canvas: -// canvas.__bench_chart Chart.js instance, set in constructChart. -// canvas.__bench_payload Last-fetched ChartResponse (raw, -// unmodified by LTTB). Source of truth -// the tooltip + LTTB rebuild read. -// canvas.__bench_state { y: "linear"|"log", scope: number|"all" } -// — the per-chart toolbar state. -// canvas.__bench_overrides Map of series the -// user has manually toggled on this card. -// Once set, the global filter no longer -// drives that label's visibility here. -// canvas.__bench_strip_render Function bound by bindRangeStrip; called -// from any path that mutates scales.x. -// canvas.__bench_rebuild Throttled `rebuildVisibleAndUpdate` -// wrapper; called from pan/zoom/wheel. -// canvas.__bench_wheel_attached true once attachWheelPan has wired -// a wheel listener (idempotency). -// canvas.__bench_inline_trimmed true if the current payload is a -// virtual latest-100 view over a longer -// full-history x-axis. -// canvas.__bench_full_loaded true once a `?n=all` refetch has -// replaced the payload. -// canvas.__bench_full_fetch_pending true while a `?n=all` refetch is in -// flight; dedupes queue/pan promotion. -// canvas.__bench_full_fetch_entry Full-history queue entry, if the fetch -// is queued but not yet complete. -// canvas.__bench_payload_window The server-side commit window used for -// the current payload (`"100"` for -// shard hydration, `"all"` for warmed -// full history). -// canvas.__bench_display_unit The picked display unit (`format`, -// `axisLabel`, `multiplier`) used by the -// tooltip and y-axis label. Recomputed -// after every payload swap and after each -// LTTB rebuild changes the visible window. -// canvas.__bench_y_user_set true once the user has explicitly -// clicked the per-chart Y-axis toolbar. -// The per-group Y override skips charts -// where this flag is set so the local -// click stays sticky. -// -// Per-card DOM contract — every selector the chart cards are queried by: -// .chart-card[data-chart-index][data-chart-slug] The card itself. -// canvas[data-chart-index] The chart canvas. -// .chart-tooltip-host External tooltip host. -// .chart-wrap Canvas wrapper. -// [data-role="downsample-badge"] LTTB badge slot. -// [data-role="scope-slider"] Toolbar scope slider. -// .toolbar--card Toolbar root. -// .toolbar-btn[data-y] Y-axis switch buttons. -// [data-role="range-strip"] Range scrollbar root. -// [data-role="range-window"] Range strip's window. -// [data-role="range-handle-left"] Left resize handle. -// [data-role="range-handle-right"] Right resize handle. -// .group-disclosure The
wrapper. -// .group-details The wrapping
. -// [data-role="global-filter-bar"] Filter dropdown root. -// [data-role="filter-trigger"] Filter dropdown button. -// [data-role="filter-panel"] Filter dropdown body. -// .filter-chip[data-filter][data-value] A single filter chip. -// [data-role="filter-badge"] Badge on the trigger. -// [data-action="expand-all"] Header button. -// [data-action="collapse-all"] Header button. -// [data-role="theme-toggle"] Header button. -// #bench-filter-state Server-emitted filter -// state JSON (script id). -(function () { - "use strict"; - - // ----------------------------------------------------------------------- - // Constants - // ----------------------------------------------------------------------- - var ZOOM_THROTTLE_MS = 16; // one frame at ~60fps for slider drag - var PAN_THROTTLE_MS = 50; // pan/zoom throttle — looser than slider - var FETCH_N = "all"; // explicit full-history upgrade - var DEFAULT_VISIBLE = 100; // initial visible window (last 100 of fetched) - var CHART_FETCH_N = String(DEFAULT_VISIBLE); // materialized shard window - var HYDRATION_CONCURRENCY = 4; // per-tab cap for latest-100 shard requests - var FULL_HISTORY_CONCURRENCY = 2; // per-tab cap for background `?n=all` - var GROUP_OPEN_PRIORITY_STEP = 100; - var INTERACTION_FULL_PRIORITY = 1000000; - - // Resolve the default scope for a chart card. Group-open hydration always - // starts with the latest-100 visible window, even when the x-axis is virtual - // and spans the full chart history. - function defaultScopeForCard(_card) { - return DEFAULT_VISIBLE; - } - // Hard cap on how many points a single series can render at once. When - // the visible commit range has more raw non-null points than this, we - // LTTB-downsample to exactly this number; below it we render raw. So - // the user always sees at most this many points per series, regardless - // of how far they zoom out, and the rule is one sentence: - // - // visible <= MAX_VISIBLE_POINTS → raw - // visible > MAX_VISIBLE_POINTS → LTTB to MAX_VISIBLE_POINTS - // - // Chart cards are ~600–900px on desktop and Chart.js draws ~2px point - // markers, so 500 points gives roughly 1.5px of horizontal space per - // point — about as dense as the eye can resolve. Bumping higher costs - // render time without visible improvement; lowering loses detail on - // wide cards. - var MAX_VISIBLE_POINTS = 500; - - // ----------------------------------------------------------------------- - // Global filter state (engine/format chips inside the navbar dropdown). - // - // Model: - // `globalFilter.engines` / `.formats` track the *active* (visible) set - // for that dimension. The chip's displayed active state mirrors - // visibility — every chip active means no filter is applied, exactly - // one chip inactive hides only that engine/format, and so on. The - // URL `?engine=`/`?format=` stay as allowlists for stability across - // refreshes; we omit the param when every chip is active (i.e. the - // active set equals the universe), so the no-filter URL is clean. - // - // Per-card overrides: - // Clicking a chart's legend toggles `dataset.hidden` and adds the label - // to that card's `canvas.__bench_overrides` set. The global apply pass - // skips overridden labels, so the user's manual call sticks even after - // subsequent global filter changes. - // ----------------------------------------------------------------------- - var globalFilter = readFilterState(); - var filterUniverse = readFilterUniverseFromDom(); - // `seedFromUrl` translates the URL state (allowlist) into the active set. - // Empty allowlist in the URL is treated as "no filter" → every chip - // active. Non-empty is taken verbatim, even if a chip has since been - // added or removed from the universe — keeps stale URLs deterministic. - seedActiveFromUrlState(); - - function readFilterState() { - var fallback = { engines: [], formats: [] }; - var node = document.getElementById("bench-filter-state"); - if (!node) return fallback; - try { - var parsed = JSON.parse(node.textContent); - return { - engines: Array.isArray(parsed.engines) ? parsed.engines.slice() : [], - formats: Array.isArray(parsed.formats) ? parsed.formats.slice() : [], - }; - } catch (e) { - return fallback; - } - } - - // Pull the chip universe straight from the rendered panel, so the JS - // doesn't have to mirror the server's enum. If the dropdown isn't on the - // page (shouldn't happen — the header always renders it when there's - // data) we fall back to whatever is in the URL state. - function readFilterUniverseFromDom() { - var u = { engines: [], formats: [] }; - document.querySelectorAll( - '[data-role="filter-panel"] .filter-chip[data-value]:not([data-value="*"])', - ).forEach(function (chip) { - var dim = chip.getAttribute("data-filter"); - var value = chip.getAttribute("data-value"); - if (!dim || !value) return; - var bucket = dim === "engine" ? u.engines : u.formats; - if (bucket.indexOf(value) === -1) bucket.push(value); - }); - return u; - } - - function seedActiveFromUrlState() { - if (!globalFilter.engines.length) { - globalFilter.engines = filterUniverse.engines.slice(); - } - if (!globalFilter.formats.length) { - globalFilter.formats = filterUniverse.formats.slice(); - } - } - - // Any-of-universe-missing-from-active means the dimension is filtered. - function dimensionIsFiltered(key) { - return globalFilter[key].length < filterUniverse[key].length; - } - - // A series is hidden when its engine/format dimension is filtered AND its - // tag isn't in the active set. Series without an engine tag (e.g. - // compression-time `format:op` series) are unaffected by the engine - // filter — symmetric for format. This keeps the chip semantics intuitive: - // hiding an engine doesn't nuke charts that have no engine dimension. - function seriesPassesFilter(meta) { - if (!meta) meta = {}; - if (meta.engine && dimensionIsFiltered("engines") - && globalFilter.engines.indexOf(meta.engine) === -1) { - return false; - } - if (meta.format && dimensionIsFiltered("formats") - && globalFilter.formats.indexOf(meta.format) === -1) { - return false; - } - return true; - } - - // Per-group filter layer. State is a single `hiddenSeries` array of dataset - // labels the user has toggled off via the group's filter dropdown. Engine - // and format chips in the dropdown are macros: clicking them bulk-toggles - // every known series whose `engine`/`format` matches (see - // `applyMacroToHiddenSeries`). The series list itself populates as charts - // in the group hydrate and surface their `payload.series_meta`. - function seriesPassesGroupFilter(filter, label) { - if (!filter || !filter.hiddenSeries) return true; - return filter.hiddenSeries.indexOf(label) === -1; - } - - // ----------------------------------------------------------------------- - // Palette + helpers - // ----------------------------------------------------------------------- - var palette = [ - "#2563eb", "#dc2626", "#16a34a", "#ea580c", "#7c3aed", - "#0891b2", "#ca8a04", "#db2777", "#65a30d", "#475569", - ]; - - function colorFor(i) { return palette[i % palette.length]; } - - function shortSha(sha) { - return typeof sha === "string" ? sha.slice(0, 7) : String(sha); - } - - function shortDate(ts) { - if (typeof ts !== "string") return ""; - return ts.slice(0, 10); - } - - function truncate(s, max) { - if (typeof s !== "string") return ""; - return s.length > max ? s.slice(0, max - 1) + "…" : s; - } - - function firstLine(s) { - if (typeof s !== "string") return ""; - var nl = s.indexOf("\n"); - return nl >= 0 ? s.slice(0, nl) : s; - } - - // Vortex commits to `develop` are squash-merged from PRs; the squash subject - // ends with `(#NNNN)`. Returning just the number lets callers build either a - // PR or commit URL. - function parsePrNumber(message) { - if (typeof message !== "string") return null; - var m = message.match(/\(#(\d+)\)/); - return m ? m[1] : null; - } - - function escapeHtml(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - // ----------------------------------------------------------------------- - // Display unit picker. The wire payload's `unit_kind` says *what* the - // values are (`time_ns`, `bytes`, …); this helper turns that plus the - // magnitude of the loaded values into a `(multiplier, suffix, axisLabel, - // decimals)` tuple. The chart locks that tuple on construction (and again - // after the lazy `?n=all` refetch swaps the payload) so the y-axis stays - // stable while the user pans/zooms — recomputing per-frame would shift - // the unit out from under them. - // - // Worked example: a `time_ns` series whose median is 12,000,000,000 ns - // picks `{ multiplier: 1e-9, suffix: "s", axisLabel: "Time (s)" }`, so - // `12,000,000,000` renders as `12 s` on the axis and in the tooltip. - // ----------------------------------------------------------------------- - var IDENTITY_UNIT = { - multiplier: 1, - suffix: "", - axisLabel: "", - decimals: 2, - }; - - // Median of finite, nonzero |v|. Zeros and NaNs aren't informative for the - // magnitude pick (a chart with all zeros isn't readable anyway), so we - // skip them; if every value is filtered out, return `null` and callers - // fall back to the kind's smallest display unit. - function magnitudeReference(values) { - if (!Array.isArray(values) || values.length === 0) return null; - var sample = []; - for (var i = 0; i < values.length; i++) { - var v = values[i]; - if (v === null || v === undefined) continue; - if (typeof v !== "number" || !Number.isFinite(v)) continue; - var a = Math.abs(v); - if (a === 0) continue; - sample.push(a); - } - if (sample.length === 0) return null; - sample.sort(function (a, b) { return a - b; }); - var mid = Math.floor(sample.length / 2); - return (sample.length % 2) - ? sample[mid] - : (sample[mid - 1] + sample[mid]) / 2; - } - - // Walk every series in the loaded payload and concatenate the non-null - // values. The picker works off the merged distribution so a chart with one - // very fast and one very slow series still picks the unit that keeps the - // larger magnitudes readable. Toggling a series visibility via the global - // filter does NOT call this — the unit is locked at payload-load time. - function collectAllValues(payload) { - var out = []; - var series = (payload && payload.series) || {}; - var keys = Object.keys(series); - for (var i = 0; i < keys.length; i++) { - var arr = series[keys[i]]; - if (!Array.isArray(arr)) continue; - for (var j = 0; j < arr.length; j++) { - var v = arr[j]; - if (v !== null && v !== undefined && Number.isFinite(v)) out.push(v); - } - } - return out; - } - - function pickTimeUnit(ref) { - // Steps: ns → µs (1e3) → ms (1e6) → s (1e9). Pick by the median's - // magnitude so the y-axis tick numbers fit in 1–4 digits. - if (ref === null || ref < 1e3) { - return { multiplier: 1, suffix: "ns", decimals: 0 }; - } - if (ref < 1e6) return { multiplier: 1e-3, suffix: "µs", decimals: 2 }; - if (ref < 1e9) return { multiplier: 1e-6, suffix: "ms", decimals: 2 }; - return { multiplier: 1e-9, suffix: "s", decimals: 2 }; - } - - function pickBytesUnit(ref) { - // Binary multiples to match how DuckDB and on-disk file sizes are - // typically reported. Steps: B → KiB (1024) → MiB → GiB → TiB. - var k = 1024; - if (ref === null || ref < k) { - return { multiplier: 1, suffix: "B", decimals: 0 }; - } - if (ref < k * k) return { multiplier: 1 / k, suffix: "KiB", decimals: 2 }; - if (ref < k * k * k) return { multiplier: 1 / (k * k), suffix: "MiB", decimals: 2 }; - if (ref < k * k * k * k) return { multiplier: 1 / (k * k * k), suffix: "GiB", decimals: 2 }; - return { multiplier: 1 / (k * k * k * k), suffix: "TiB", decimals: 2 }; - } - - function pickDisplayUnit(unitKind, values) { - var ref = magnitudeReference(values); - if (unitKind === "time_ns") { - var t = pickTimeUnit(ref); - return { - multiplier: t.multiplier, - suffix: t.suffix, - axisLabel: "Time (" + t.suffix + ")", - decimals: t.decimals, - }; - } - if (unitKind === "bytes") { - var b = pickBytesUnit(ref); - return { - multiplier: b.multiplier, - suffix: b.suffix, - axisLabel: "Size (" + b.suffix + ")", - decimals: b.decimals, - }; - } - if (unitKind === "throughput_mb_s") { - return { - multiplier: 1, - suffix: "MB/s", - axisLabel: "Throughput (MB/s)", - decimals: 2, - }; - } - if (unitKind === "ratio" || unitKind === "count") { - // Dimensionless: no scaling, no suffix, no axis title — leaving the - // axis unlabeled keeps a "1.2× speedup" axis from being read as - // "1200 m" by an axis-title-driven label. - return { - multiplier: 1, - suffix: "", - axisLabel: "", - decimals: unitKind === "count" ? 0 : 2, - }; - } - // Unknown kind (forward-compat with a future server enum). Identity is - // the safest fallback — values render verbatim, no unit. - return IDENTITY_UNIT; - } - - // Tooltip formatter: applies the chart's locked display unit so the tooltip - // value matches the y-axis tick numbers exactly. Raw `null`/`NaN` collapse - // to an em-dash so a missing data point reads as a clear gap rather than - // a literal `0`. - function formatDisplayValue(rawValue, displayUnit) { - if (rawValue === null || rawValue === undefined || Number.isNaN(rawValue)) { - return "—"; - } - var u = displayUnit || IDENTITY_UNIT; - var scaled = rawValue * u.multiplier; - var text = Number.isFinite(scaled) ? scaled.toFixed(u.decimals) : "—"; - return u.suffix ? text + " " + u.suffix : text; - } - - // Throttle to a max call rate; trailing call is preserved so the final - // slider position is honoured. (`requestAnimationFrame` is conceptually - // similar but we want a hard ceiling regardless of when the browser - // schedules a frame.) - function throttle(fn, ms) { - var lastRan = 0; - var pending = null; - var pendingArgs = null; - return function () { - var now = Date.now(); - pendingArgs = arguments; - if (now - lastRan >= ms) { - lastRan = now; - fn.apply(null, pendingArgs); - } else if (!pending) { - var wait = ms - (now - lastRan); - pending = setTimeout(function () { - lastRan = Date.now(); - pending = null; - fn.apply(null, pendingArgs); - }, wait); - } - }; - } - - // ----------------------------------------------------------------------- - // LTTB (Largest-Triangle-Three-Buckets) downsampler. - // - // Returns the indices into `xs` / `ys` to keep, including index 0 and - // `n - 1`. `xs` must be strictly increasing. When `threshold >= n` or - // `threshold < 3`, returns `[0, 1, ..., n-1]` unchanged. - // - // Algorithm: . Per-bucket pick the - // point that forms the largest triangle with the previously kept point - // and the average of the next bucket. - // ----------------------------------------------------------------------- - function lttbIndices(xs, ys, threshold) { - var n = xs.length; - if (threshold >= n || threshold < 3) { - var all = new Array(n); - for (var i = 0; i < n; i++) all[i] = i; - return all; - } - var out = new Array(threshold); - out[0] = 0; - var bucket = (n - 2) / (threshold - 2); - var a = 0; - for (var bi = 0; bi < threshold - 2; bi++) { - // Average of the *next* bucket — the "C" point in the triangle. - var nextStart = Math.floor((bi + 1) * bucket) + 1; - var nextEnd = Math.min(n, Math.floor((bi + 2) * bucket) + 1); - var count = Math.max(1, nextEnd - nextStart); - var ax = 0, ay = 0; - for (var j = nextStart; j < nextEnd; j++) { ax += xs[j]; ay += ys[j]; } - ax /= count; ay /= count; - - // Search this bucket for the point with the largest triangle area - // against (a, avg_next). - var rangeStart = Math.floor(bi * bucket) + 1; - var rangeEnd = Math.floor((bi + 1) * bucket) + 1; - var pax = xs[a], pay = ys[a]; - var maxArea = -1; - var maxIdx = rangeStart; - for (var k = rangeStart; k < rangeEnd; k++) { - var area = Math.abs((pax - ax) * (ys[k] - pay) - (pax - xs[k]) * (ay - pay)) * 0.5; - if (area > maxArea) { maxArea = area; maxIdx = k; } - } - out[bi + 1] = maxIdx; - a = maxIdx; - } - out[threshold - 1] = n - 1; - return out; - } - - // ----------------------------------------------------------------------- - // Crosshair plugin: draws a vertical line at the chart's active hover - // index. Using an inline plugin is cheaper than pulling in - // chartjs-plugin-crosshair, which is overkill for this one feature. - // ----------------------------------------------------------------------- - var crosshairPlugin = { - id: "benchCrosshair", - afterDatasetsDraw: function (chart) { - var active = chart.tooltip && chart.tooltip.getActiveElements - ? chart.tooltip.getActiveElements() - : []; - if (!active || !active.length) return; - var x = active[0].element.x; - var ya = chart.scales && chart.scales.y; - if (!ya || !Number.isFinite(x)) return; - var ctx = chart.ctx; - ctx.save(); - // `--muted` from the page theme — read it lazily so dark mode picks - // up the right colour. - var muted = getComputedStyle(document.documentElement) - .getPropertyValue("--muted").trim() || "#9ca3af"; - ctx.strokeStyle = muted; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(x, ya.top); - ctx.lineTo(x, ya.bottom); - ctx.stroke(); - ctx.restore(); - }, - }; - - // ----------------------------------------------------------------------- - // External tooltip with offset + flip-on-overflow. - // - // Factory contract: returns a Chart.js `external` tooltip handler closed - // over `canvas` (the rendered canvas element, used to read the cached - // payload via `canvas.__bench_payload`) and `host` (the - // `
` element to render markup into; - // `host.parentNode` is the chart-card and is used as the positioning - // origin). The returned handler is invoked by Chart.js with one argument - // `context = { tooltip, chart }`; it mutates `host` in place and is a - // no-op when `tooltip.opacity === 0`. - // - // Flicker fix: the tooltip host is **always** `pointer-events: none`. The - // previous implementation flipped it to `auto` when visible; the cursor - // would land on the tooltip, fire mouseout on the canvas, the tooltip - // would hide, the cursor would re-enter the canvas, and the cycle would - // repeat at event-loop frequency. Clicks on a data point are handled by - // the chart's `onClick` (opens the PR or commit URL in a new tab), so the - // tooltip itself never needs to be interactive. - // ----------------------------------------------------------------------- - function externalTooltipHandler(canvas, host) { - return function (context) { - var tt = context.tooltip; - if (!host) return; - if (tt.opacity === 0) { - host.style.opacity = "0"; - return; - } - - var chart = context.chart; - var payload = canvas.__bench_payload || { commits: [] }; - var firstDp = tt.dataPoints && tt.dataPoints[0]; - if (!firstDp) { - host.style.opacity = "0"; - return; - } - // Snap to a single commit. We use `mode: "nearest"` on the chart - // options, so `firstDp.dataIndex` is the single closest data point - // to the cursor (skipping nulls in `dataset.data`). If the cursor - // falls between two LTTB-kept points, exactly one wins — no more - // rendering both columns at once. - var idx = firstDp.dataIndex; - var commit = (payload.commits || [])[idx] || {}; - // Tooltip values must match the locked y-axis unit. Raw values still - // live on `dataset.rawData`; the display unit is what scales them - // into ms / MiB / etc. for the visible text. - var displayUnit = canvas.__bench_display_unit || IDENTITY_UNIT; - var rawLen = (chart.data.labels || []).length; - - // Build one row per dataset, reading values from each series' - // `rawData` (the unmodified payload) so the tooltip shows raw - // measurements even when LTTB has nulled out `dataset.data[idx]`. - // Iterating `chart.data.datasets` directly — instead of mapping - // `tt.dataPoints` — guarantees one row per series at this single - // commit; `tt.dataPoints` could otherwise contain points from - // multiple `dataIndex` values when the cursor sits between two - // closely-packed LTTB columns. - var rowItems = chart.data.datasets.map(function (ds, dsIndex) { - // Skip datasets the user (or filter bar) has hidden. - var meta = chart.getDatasetMeta && chart.getDatasetMeta(dsIndex); - if (meta && meta.hidden) return null; - if (ds.hidden) return null; - var raw = (ds.rawData || [])[idx]; - if (raw === null || raw === undefined || Number.isNaN(raw)) { - return null; - } - // Per-row delta is `(current - previous) / previous`, where - // "previous" is the chronologically preceding commit. The - // `commits[]` array is sorted oldest-first by SQL — index 0 is - // the oldest commit, the last index is the newest — so the - // predecessor lives at `idx - 1`. Walk further back across - // null-valued slots so series that didn't run on every commit - // still get a meaningful baseline. - var prevIdx = idx - 1; - var prevRaw = null; - while (prevIdx >= 0) { - var pv = (ds.rawData || [])[prevIdx]; - if (pv !== null && pv !== undefined && !Number.isNaN(pv)) { - prevRaw = pv; - break; - } - prevIdx--; - } - var deltaHtml = ""; - if (prevRaw !== null && prevRaw !== 0) { - var pct = ((raw - prevRaw) / prevRaw) * 100; - var cls = pct > 0 ? "tt-delta tt-delta--worse" - : pct < 0 ? "tt-delta tt-delta--better" : "tt-delta"; - var sign = pct > 0 ? "+" : ""; - deltaHtml = '' + sign + pct.toFixed(1) + "%"; - } - return { - label: ds.label, - color: ds.borderColor, - raw: raw, - deltaHtml: deltaHtml, - }; - }).filter(Boolean); - - // Top-to-bottom order matches the visual stack of lines at this x. - rowItems.sort(function (a, b) { return b.raw - a.raw; }); - - var rows = rowItems - .map(function (r) { - return '
' - + '' - + '' + escapeHtml(r.label) + '' - + '' - + escapeHtml(formatDisplayValue(r.raw, displayUnit)) + '' - + r.deltaHtml - + "
"; - }) - .join(""); - - // If every series was hidden / had no value at this commit, treat - // this as a no-op hover instead of flashing an empty popup. - if (!rows) { - host.style.opacity = "0"; - return; - } - - var titleHtml = '
' - + escapeHtml(shortSha(commit.sha)) + ' · ' - + escapeHtml(shortDate(commit.timestamp)) - + "
"; - - // Show short SHA + first-line commit message, truncated. The full URL - // (or PR URL) is wired up via the chart's onClick handler, so we don't - // render it as text here. - var msg = truncate(firstLine(commit.message || ""), 80); - var footerLine = commit.sha - ? (msg ? escapeHtml(shortSha(commit.sha)) + " · " + escapeHtml(msg) - : escapeHtml(shortSha(commit.sha))) - : escapeHtml(msg); - var footerHtml = footerLine - ? '" - : ""; - - host.innerHTML = titleHtml + '
' + rows + "
" + footerHtml; - - // Position the tooltip relative to its container, offset 12px from - // the cursor. Flip horizontally if it would overflow. - var canvasRect = context.chart.canvas.getBoundingClientRect(); - var hostRect = host.parentNode.getBoundingClientRect(); - var x = canvasRect.left - hostRect.left + tt.caretX; - var y = canvasRect.top - hostRect.top + tt.caretY; - host.style.opacity = "1"; - host.style.left = x + "px"; - host.style.top = y + "px"; - // Measure after content swap so flipping is correct. - var ttWidth = host.offsetWidth || 0; - var containerWidth = host.parentNode.clientWidth || 0; - var flip = (x + ttWidth + 24) > containerWidth; - host.style.transform = flip - ? "translate(calc(-100% - 12px), 12px)" - : "translate(12px, 12px)"; - }; - } - - // ----------------------------------------------------------------------- - // Payload + datasets - // ----------------------------------------------------------------------- - function readInlinePayload(idx) { - var s = document.getElementById("chart-data-" + idx); - if (!s) return null; - try { return JSON.parse(s.textContent); } catch (e) { return null; } - } - - function labelForCommit(commit) { - return commit && commit.sha ? shortSha(commit.sha) : ""; - } - - function canonicalHistory(payload) { - var commits = Array.isArray(payload && payload.commits) ? payload.commits : []; - var history = payload && payload.history ? payload.history : {}; - var loaded = Number.isFinite(history.loaded_commits) - ? history.loaded_commits - : commits.length; - var total = Number.isFinite(history.total_commits) - ? history.total_commits - : commits.length; - var start = Number.isFinite(history.start_index) ? history.start_index : 0; - loaded = Math.max(0, Math.floor(loaded)); - total = Math.max(loaded, Math.floor(total)); - start = Math.max(0, Math.min(Math.floor(start), Math.max(0, total - loaded))); - return { - total_commits: total, - start_index: start, - loaded_commits: loaded, - complete: history.complete === true || (start === 0 && loaded === total), - }; - } - - function normalizeChartPayload(payload) { - if (!payload) return payload; - if (payload.__bench_normalized) return payload; - var commits = Array.isArray(payload.commits) ? payload.commits : []; - var history = canonicalHistory(payload); - if (history.complete && history.start_index === 0 && history.total_commits === commits.length) { - payload.history = history; - payload.__bench_normalized = true; - return payload; - } - - var total = history.total_commits; - var start = history.start_index; - var normalizedCommits = new Array(total); - for (var i = 0; i < total; i++) normalizedCommits[i] = null; - for (var ci = 0; ci < commits.length && start + ci < total; ci++) { - normalizedCommits[start + ci] = commits[ci]; - } - - var rawSeries = payload.series || {}; - var normalizedSeries = {}; - Object.keys(rawSeries).forEach(function (name) { - var values = Array.isArray(rawSeries[name]) ? rawSeries[name] : []; - var out = new Array(total); - for (var zi = 0; zi < total; zi++) out[zi] = null; - for (var vi = 0; vi < values.length && start + vi < total; vi++) { - out[start + vi] = values[vi]; - } - normalizedSeries[name] = out; - }); - - var clone = {}; - Object.keys(payload).forEach(function (key) { - if (key !== "commits" && key !== "series") clone[key] = payload[key]; - }); - clone.history = history; - clone.commits = normalizedCommits; - clone.series = normalizedSeries; - clone.__bench_normalized = true; - return clone; - } - - function rangeTouchesUnloadedHistory(payload, min, max) { - var history = payload && payload.history; - if (!history || history.complete) return false; - var start = history.start_index || 0; - var end = start + (history.loaded_commits || 0) - 1; - return Math.floor(min) < start || Math.ceil(max) > end; - } - - // Build the per-series dataset shells. `data` starts as a full-length - // null-padded array; `rebuildVisibleAndUpdate` fills it in based on the - // current visible range. `rawData` holds a reference to the original - // payload so the tooltip can show raw values regardless of LTTB. - function buildDatasets(payload) { - var raw = payload.series || {}; - var meta = payload.series_meta || {}; - var n = (payload.commits || []).length; - var names = Object.keys(raw).sort(); - return names.map(function (name, i) { - var seriesMeta = meta[name] || {}; - var rawValues = Array.isArray(raw[name]) ? raw[name] : []; - // `data` starts null-padded; `rebuildVisibleAndUpdate` fills the - // current visible window with raw or LTTB-kept values. Chart.js's - // `spanGaps: true` connects the line across nulls so a series with - // partial coverage (a benchmark crashed at one commit, a series - // only runs nightly, etc.) still draws as a continuous trend - // through the surrounding measurements. The point markers - // themselves are only drawn at non-null indices, so the missing - // commits are visible as a "no marker" beat in the line — the line - // itself bridges to the next available data point. - var data = new Array(n); - for (var j = 0; j < n; j++) data[j] = null; - return { - label: name, - data: data, - rawData: rawValues, - borderColor: colorFor(i), - backgroundColor: colorFor(i) + "20", - borderWidth: 1.5, - spanGaps: true, - tension: 0, - pointRadius: 2, - pointHoverRadius: 5, - pointHitRadius: 8, - pointStyle: "cross", - // Custom field (Chart.js ignores unknown keys). Used by the global - // filter to decide which datasets to hide/show in bulk. - benchMeta: { engine: seriesMeta.engine, format: seriesMeta.format }, - hidden: !seriesPassesFilter(seriesMeta), - }; - }); - } - - // ----------------------------------------------------------------------- - // The single source of truth for the rendered point count. - // - // Walks the visible `[rangeMin, rangeMax]` window of the raw payload and, - // for each series, renders raw when the visible count is at or below - // `MAX_VISIBLE_POINTS` and LTTB-downsamples to exactly that number when - // above. The result is written into `dataset.data` with nulls outside - // the kept set so Chart.js renders just the kept points; with - // `spanGaps: true`, the line connects across the nulls to the next - // non-null point so a sparse series still reads as a continuous trend. - // - // Mutates `dataset.data` in place to avoid GC churn on every pan frame. - // Updates the per-card downsample badge as a side effect. - // ----------------------------------------------------------------------- - function rebuildVisibleAndUpdate(card, chart, rangeMin, rangeMax, allowFullFetch) { - var canvas = chart.canvas; - var payload = canvas.__bench_payload; - if (!payload) return; - var datasets = chart.data.datasets; - var n = (payload.commits || []).length; - if (n === 0) return; - - var min = Math.max(0, Math.floor(rangeMin)); - var max = Math.min(n - 1, Math.ceil(rangeMax)); - if (max < min) max = min; - - // Build one "virtual series" for LTTB: walk every commit index in the - // visible range and, for each index, take the max non-null value - // across all datasets. This is the union of x-positions, with a - // representative y per position. Series in a Vortex chart share both - // unit and overall scale (they're the same benchmark with different - // engines/formats), so max-across-series picks visually salient peaks - // without per-series scale skew. - // - // This becomes our LTTB input: we then pick AT MOST MAX_VISIBLE_POINTS - // commit indices and every dataset renders only at those shared - // indices. Without this, per-series LTTB picked different peaks for - // each series and the union of x-positions grew with the series - // count — visually you saw way more than MAX_VISIBLE_POINTS dots - // even though each line only had MAX_VISIBLE_POINTS. - var unionIdxs = []; - var unionVals = []; - for (var i = min; i <= max; i++) { - var bestY = null; - for (var di = 0; di < datasets.length; di++) { - var rawValues = datasets[di].rawData; - if (!Array.isArray(rawValues)) continue; - var v = rawValues[i]; - if (v !== null && v !== undefined && !Number.isNaN(v) - && (bestY === null || v > bestY)) { - bestY = v; - } - } - if (bestY !== null) { - unionIdxs.push(i); - unionVals.push(bestY); - } - } - - // Decide which commit indices to render — shared across all series. - var keptSet = {}; - var anyDownsampled = false; - if (unionIdxs.length <= MAX_VISIBLE_POINTS) { - // Below the cap: render every commit that has data anywhere. - for (var u = 0; u < unionIdxs.length; u++) keptSet[unionIdxs[u]] = true; - } else { - // Above the cap: LTTB the union down to MAX_VISIBLE_POINTS exactly. - // The selected indices are then *shared* across every dataset; that - // is the cap's only correct interpretation of "max points on the - // chart at a time". - var localIndices = lttbIndices(unionIdxs, unionVals, MAX_VISIBLE_POINTS); - for (var li = 0; li < localIndices.length; li++) { - keptSet[unionIdxs[localIndices[li]]] = true; - } - anyDownsampled = true; - } - - // Plant the shared kept set into every dataset.data. Series that have - // no value at a kept index simply remain null there; with - // `spanGaps: true`, the line connects to the next non-null point so - // a series with partial coverage (a benchmark crashed, a series only - // runs nightly) still draws as a continuous trend through the - // surrounding measurements. Markers are only drawn at non-null - // indices, so the gap is still visible as a missing point — just not - // as a broken line. - // - // We deliberately do NOT plant nearest-neighbour values for indices - // outside `[min, max]`: extending the line past the visible edges - // sounds nice (the line goes off-screen toward the next real - // measurement instead of stopping at the rightmost in-range point), - // but Chart.js's y-axis auto-scale uses every non-null value in the - // dataset regardless of `scales.x.min/max`. An off-screen neighbour - // with a very different y value (an old benchmark configuration, a - // first-run cold cache, anything) blows up the y-axis range and - // squashes the in-window values into a flat line near the floor. - // Fixing that would mean overriding `scales.y.min/max` per rebuild - // from only the in-window values, which changes the "y-axis stays - // stable across x-zoom" UX. Out of scope here; if a user wants to - // see how the line connects across the edge they can zoom out. - // Pull the chart's locked display-unit multiplier. Applied here, not on - // ingest or in the SQL, so the wire payload stays in base units (ns, - // bytes, …) — the unit transform is purely cosmetic. - var displayUnit = canvas.__bench_display_unit || IDENTITY_UNIT; - var multiplier = displayUnit.multiplier; - for (var dj = 0; dj < datasets.length; dj++) { - var ds = datasets[dj]; - var dsRaw = ds.rawData; - if (!Array.isArray(dsRaw)) continue; - var data = ds.data; - if (!Array.isArray(data) || data.length !== n) { - data = new Array(n); - ds.data = data; - } - for (var z = 0; z < n; z++) data[z] = null; - for (var idxStr in keptSet) { - var idx = +idxStr; - var val = dsRaw[idx]; - if (val !== null && val !== undefined && !Number.isNaN(val)) { - data[idx] = val * multiplier; - } - } - } - - var visibleCommits = max - min + 1; - var keptCommits = 0; - for (var _u in keptSet) keptCommits++; - chart.update("none"); - syncSliderFromRange(card, visibleCommits); - syncDownsampleBadge(card, keptCommits, visibleCommits, anyDownsampled); - // If the user moves into the virtual, not-yet-loaded part of the x-axis, - // promote this chart's queued full-history fetch. Group-open warmup should - // usually have it in flight already; this just puts direct interaction - // ahead of background work that has not started. - if (allowFullFetch && rangeTouchesUnloadedHistory(payload, min, max)) { - ensureFullHistory(card, INTERACTION_FULL_PRIORITY); - } - } - - // ----------------------------------------------------------------------- - // Full-history warmup. Opening a group queues `?n=all` for every chart in - // that group. Direct interaction with an unloaded virtual range promotes the - // same queued entry instead of issuing a duplicate request. - // ----------------------------------------------------------------------- - function ensureFullHistory(card, priority) { - var canvas = card.querySelector("canvas"); - if (!canvas) return Promise.resolve(); - if (canvas.__bench_full_loaded) return Promise.resolve(); - if (canvas.__bench_full_fetch_entry) { - if (priority) { - canvas.__bench_full_fetch_entry.priority = Math.max( - canvas.__bench_full_fetch_entry.priority, - priority - ); - drainFullHistoryQueue(); - } - return canvas.__bench_full_fetch_pending || Promise.resolve(); - } - var slug = card.getAttribute("data-chart-slug"); - if (!slug) return Promise.resolve(); - var entry = scheduleFullHistory(function () { - var url = "/api/chart/" + encodeURIComponent(slug) - + "?n=" + encodeURIComponent(FETCH_N); - return fetch(url, { headers: { "accept": "application/json" } }) - .then(function (r) { - if (r.status === 404) return null; - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }) - .then(function (full) { - if (!full) return; - replaceChartPayload(card, full); - canvas.__bench_full_loaded = true; - canvas.__bench_inline_trimmed = false; - showCardLoading(card, false); - var group = card.closest(".group-details"); - if (!canvas.__bench_chart && (!group || groupIsOpen(group))) { - fetchAndConstruct(card); - } - }); - }, priority || 0); - canvas.__bench_full_fetch_entry = entry; - canvas.__bench_full_fetch_pending = entry.promise.catch(function (err) { - // Quiet — the latest-100 virtual payload is still usable. Surface to the - // console for debugging. - if (window && window.console) { - window.console.warn("bench: full history fetch failed", err); - } - }).then(function () { - canvas.__bench_full_fetch_entry = null; - canvas.__bench_full_fetch_pending = null; - }); - return canvas.__bench_full_fetch_pending; - } - - // Swap the chart's labels + datasets to a freshly fetched, unbounded - // payload while preserving the current x-range. The virtual latest-100 - // payload and the full payload share a full-history x-axis, so the chart - // should not jump when the real older values arrive. - function replaceChartPayload(card, payload) { - var canvas = card.querySelector("canvas"); - var chart = canvas && canvas.__bench_chart; - if (!canvas || !payload) return; - payload = normalizeChartPayload(payload); - canvas.__bench_payload = payload; - canvas.__bench_payload_window = FETCH_N; - if (!chart) return; - // Re-pick the display unit against the now-wider window. The first - // payload was the latest-100 slice; the refetch may surface older - // commits with a different magnitude, and we'd rather move the - // y-axis once at the refetch boundary than leave the chart on a - // stale unit. The axis title is updated to match. - canvas.__bench_display_unit = pickDisplayUnit( - payload.unit_kind, collectAllValues(payload), - ); - var yAxis = chart.options.scales && chart.options.scales.y; - if (yAxis && yAxis.title) { - yAxis.title.display = !!canvas.__bench_display_unit.axisLabel; - yAxis.title.text = canvas.__bench_display_unit.axisLabel; - } - var newLabels = (payload.commits || []).map(labelForCommit); - var newDatasets = buildDatasets(payload); - // Re-apply per-card legend overrides + global filter to the new datasets, - // matching the visibility state the user had before the refetch. - var overrides = canvas.__bench_overrides || {}; - for (var i = 0; i < newDatasets.length; i++) { - var ds = newDatasets[i]; - if (overrides[ds.label]) { - // Honour any explicit legend toggle the user had made already. - var prev = chart.data.datasets.find(function (p) { - return p.label === ds.label; - }); - if (prev) ds.hidden = !!prev.hidden; - } - } - chart.data.labels = newLabels; - chart.data.datasets = newDatasets; - // Re-evaluate per-group + global filter on the swapped dataset so the - // visibility state matches what was on screen before the refetch. Also - // refresh the group's series chip row in case the wider window surfaces - // a series that was absent from the inline payload. - applyFiltersToChart(card); - noteSeriesFromCard(card); - var newMaxIdx = Math.max(0, newLabels.length - 1); - var zoomLimits = chart.options.plugins - && chart.options.plugins.zoom - && chart.options.plugins.zoom.limits - && chart.options.plugins.zoom.limits.x; - if (zoomLimits) { - zoomLimits.max = newMaxIdx; - } - syncSliderBounds(card, newLabels.length); - var sx = chart.options.scales.x; - var prevMin = Number.isFinite(sx.min) ? sx.min : 0; - var prevMax = Number.isFinite(sx.max) ? sx.max : 0; - if (Number.isFinite(prevMin) && Number.isFinite(prevMax)) { - sx.min = Math.max(0, Math.min(newMaxIdx, prevMin)); - sx.max = Math.max(sx.min, Math.min(newMaxIdx, prevMax)); - } else { - var prevVisible = Math.max(1, prevMax - prevMin + 1); - sx.max = newMaxIdx; - sx.min = Math.max(0, newMaxIdx - (prevVisible - 1)); - } - rebuildVisibleAndUpdate(card, chart, sx.min, sx.max); - if (canvas.__bench_strip_render) canvas.__bench_strip_render(); - } - - // Mirror the chart's current visible commit count onto the toolbar - // slider. Called from `rebuildVisibleAndUpdate` so every path that - // changes the visible range — toolbar slider drag, drag-pan, - // drag-rectangle-zoom, wheel-pan, range-strip drag — keeps the - // slider in sync. Programmatic value writes do not fire the slider's - // `input` event, so this never re-enters `applyScope`. - function syncSliderFromRange(card, visibleCommits) { - var slider = card.querySelector('[data-role="scope-slider"]'); - if (!slider) return; - var lo = parseInt(slider.min, 10) || 1; - var hi = parseInt(slider.max, 10) || visibleCommits; - slider.value = String(Math.max(lo, Math.min(hi, visibleCommits))); - } - - // Show the badge when at least one series in the visible range was - // downsampled. The numbers are commit counts: how many distinct - // commits the chart is rendering, and how many are in the visible - // range. Both come from the slider's mental model so "300 / 3000" in - // the badge matches "showing the last 3000" on the slider. - function syncDownsampleBadge(card, keptCommits, visibleCommits, anyDownsampled) { - var badge = card.querySelector('[data-role="downsample-badge"]'); - if (!badge) return; - if (!anyDownsampled || keptCommits >= visibleCommits) { - badge.setAttribute("hidden", ""); - badge.textContent = ""; - return; - } - badge.removeAttribute("hidden"); - badge.textContent = "downsampled · " + keptCommits + " / " + visibleCommits; - badge.setAttribute( - "title", - "Showing " + keptCommits + " of " + visibleCommits - + " commits in view. Each series renders at most " - + MAX_VISIBLE_POINTS + " points at a time; when more are in " - + "view, we apply LTTB (Largest Triangle, Three Buckets), an " - + "algorithm that picks representative points by maximising " - + "the area of triangles formed with neighbouring buckets. " - + "Visual peaks and valleys are preserved while the chart " - + "stays responsive. Zoom in past " + MAX_VISIBLE_POINTS - + " visible commits to see every raw measurement." - ); - } - - // ----------------------------------------------------------------------- - // Per-card construction. The set of `canvas.__bench_*` fields planted - // by this function (and read elsewhere) is documented at the top of - // this file under "Canvas state contract". - // ----------------------------------------------------------------------- - function constructChart(card) { - var idx = card.getAttribute("data-chart-index"); - var canvas = card.querySelector('canvas[data-chart-index="' + idx + '"]'); - if (!canvas || typeof Chart === "undefined") return null; - if (canvas.__bench_chart) return canvas.__bench_chart; - - var payload = normalizeChartPayload(canvas.__bench_payload || readInlinePayload(idx)); - if (!payload) return null; - canvas.__bench_payload = payload; - // Latest-100 payloads are normalized onto the full x-axis. `history` - // tells us whether old indices are virtual placeholders or real data. - if (canvas.__bench_full_loaded === undefined) { - var history = canonicalHistory(payload); - canvas.__bench_full_loaded = !!history.complete; - canvas.__bench_inline_trimmed = !canvas.__bench_full_loaded; - } - - var state = canvas.__bench_state - || { y: "linear", scope: defaultScopeForCard(card) }; - canvas.__bench_state = state; - // Series labels the user has explicitly toggled on this card. Once a - // label lands here, the global filter no longer drives that series's - // hidden-state on this card — only direct legend clicks do. - if (!canvas.__bench_overrides) canvas.__bench_overrides = {}; - // Lock the display unit for the lifetime of this loaded payload. We - // recompute only when `replaceChartPayload` swaps in a wider window - // after a `?n=all` refetch — toggling a series via the global filter - // never touches it. See `pickDisplayUnit` for the full rationale. - canvas.__bench_display_unit = pickDisplayUnit( - payload.unit_kind, collectAllValues(payload), - ); - - var labels = (payload.commits || []).map(labelForCommit); - var datasets = buildDatasets(payload); - var host = card.querySelector(".chart-tooltip-host"); - var range = visibleRange(labels.length, state.scope); - var legendPosition = (window.matchMedia - && window.matchMedia("(max-width: 768px)").matches) ? "top" : "bottom"; - - // Throttled rebuild for pan/zoom. Both axes mutate scales.x.min/max - // continuously during interaction, so we re-derive the rendered - // points on every frame (capped to PAN_THROTTLE_MS) and refresh the - // range strip to match. Single throttle so LTTB and the strip never - // diverge. - var throttledRebuild = throttle(function (chart) { - var sx = chart.scales && chart.scales.x; - if (!sx) return; - rebuildVisibleAndUpdate(card, chart, sx.min, sx.max, true); - if (canvas.__bench_strip_render) canvas.__bench_strip_render(); - }, PAN_THROTTLE_MS); - - var chart = new Chart(canvas, { - type: "line", - data: { labels: labels, datasets: datasets }, - plugins: [crosshairPlugin], - options: { - responsive: true, - maintainAspectRatio: false, - animation: false, - // Snap to the single nearest commit *that has rendered data*. - // After LTTB downsampling most commit indices are null in - // `dataset.data`; `mode: "index"` would happily pick one of - // those null indices and produce an empty tooltip, while - // `mode: "x"` would pick multiple closely-packed LTTB columns - // at once and the tooltip would render duplicate rows for the - // same series at different commits. `mode: "nearest"` returns - // exactly one closest data point — its `dataIndex` is then - // used by the external handler as the single hovered commit, - // and the handler iterates `chart.data.datasets` itself to - // build one row per series. `intersect: false` keeps it - // active anywhere on the chart and, combined with - // `pointer-events: none` on the tooltip host, is also the - // flicker fix. - interaction: { mode: "nearest", intersect: false, axis: "x" }, - onClick: function (event, _activeElements, chart) { - var points = chart.getElementsAtEventForMode( - event, "nearest", { intersect: false, axis: "x" }, true, - ); - if (!points.length) return; - var pIdx = points[0].index; - var commits = (canvas.__bench_payload || {}).commits || []; - var commit = commits[pIdx]; - if (!commit) return; - var pr = parsePrNumber(commit.message); - var url = pr - ? "https://github.com/vortex-data/vortex/pull/" + pr - : commit.url; - if (url) window.open(url, "_blank", "noopener"); - }, - scales: { - y: { - type: state.y === "log" ? "logarithmic" : "linear", - beginAtZero: state.y !== "log", - // Axis title reflects the locked display unit. Empty string when - // the kind is dimensionless (`ratio`, `count`) so a "1.2× speedup" - // chart doesn't get an arbitrary "value" label and a "12 m" chart - // doesn't get read as anything other than `12 ms` / `12 s` / etc. - title: { - display: !!canvas.__bench_display_unit.axisLabel, - text: canvas.__bench_display_unit.axisLabel, - }, - }, - x: { - min: range.min, - max: range.max, - title: { display: false }, - // With a 5000-commit history rendering one tick per commit - // is unreadable anyway. Cap it; Chart.js will pick a sensible - // subset of label indices to draw. - ticks: { maxTicksLimit: 12, autoSkip: true }, - }, - }, - plugins: { - legend: { - position: legendPosition, - // Wrap the default toggle so we record the per-card override - // and keep `dataset.hidden` in sync with the legend's - // `_hiddenInLegend` flag — the global filter pass writes to - // `dataset.hidden`, so they need to track each other or - // subsequent global changes look stale. - onClick: function (e, item, legend) { - var ci = legend.chart; - var ds = ci.data.datasets[item.datasetIndex]; - var label = ds && ds.label; - if (label && ci.canvas && ci.canvas.__bench_overrides) { - ci.canvas.__bench_overrides[label] = true; - } - var visible = ci.isDatasetVisible(item.datasetIndex); - ci.setDatasetVisibility(item.datasetIndex, !visible); - if (ds) ds.hidden = visible; // flipped: was visible → now hidden, etc. - ci.update(); - }, - }, - tooltip: { - enabled: false, - external: externalTooltipHandler(canvas, host), - // Row ordering is handled inside the external handler now — - // we iterate `chart.data.datasets` ourselves rather than the - // tooltip's `dataPoints`, so `itemSort` here would be dead - // code. - }, - // chartjs-plugin-zoom config — wheel-zoom is disabled because we - // want wheel-pan instead (handled by the canvas wheel listener - // below). Drag-pan and drag-rectangle-zoom are free. - zoom: { - zoom: { - wheel: { enabled: false }, - drag: { - enabled: true, - backgroundColor: "rgba(37, 99, 235, 0.10)", - }, - mode: "x", - onZoom: function (ctx) { throttledRebuild(ctx.chart); }, - }, - pan: { - enabled: true, - mode: "x", - modifierKey: null, - onPan: function (ctx) { throttledRebuild(ctx.chart); }, - }, - limits: { - x: { min: 0, max: Math.max(0, labels.length - 1), minRange: 4 }, - }, - }, - }, - }, - }); - - canvas.__bench_chart = chart; - canvas.__bench_rebuild = throttledRebuild; - attachWheelPan(canvas, chart, throttledRebuild); - syncSliderBounds(card, labels.length); - // Initial render: the chart is constructed with empty (null) data; - // populate it for the initial visible window. Strip is bound after the - // rebuild so its first paint reflects the same range Chart.js shows. - rebuildVisibleAndUpdate(card, chart, range.min, range.max); - bindRangeStrip(card, chart); - if (canvas.__bench_strip_render) canvas.__bench_strip_render(); - // `buildDatasets` seeded `hidden` from the global filter; reapply through - // the layered helper so a per-group filter set before this card hydrated - // also takes effect. Then surface this card's series labels to the - // group's filter dropdown so the chip row picks them up. - applyFiltersToChart(card); - noteSeriesFromCard(card); - return chart; - } - - // ----------------------------------------------------------------------- - // Range scrollbar strip — the thin track below each canvas. Spans the full - // commit history; the highlighted "window" matches the chart's currently - // visible x-range and can be dragged or its edges resized to pan/zoom. - // ----------------------------------------------------------------------- - function bindRangeStrip(card, chart) { - var strip = card.querySelector('[data-role="range-strip"]'); - if (!strip || strip.__bench_bound) return; - strip.__bench_bound = true; - var win = strip.querySelector('[data-role="range-window"]'); - var leftHandle = strip.querySelector('[data-role="range-handle-left"]'); - var rightHandle = strip.querySelector('[data-role="range-handle-right"]'); - if (!win || !leftHandle || !rightHandle) return; - - var canvas = card.querySelector("canvas"); - - function commitCount() { - return (chart.data.labels || []).length; - } - - function visibleBounds() { - var n = commitCount(); - if (n <= 0) return { min: 0, max: 0 }; - var maxIdx = n - 1; - var sx = chart.options.scales.x || {}; - var min = Number.isFinite(sx.min) ? sx.min : 0; - var max = Number.isFinite(sx.max) ? sx.max : maxIdx; - min = Math.max(0, Math.min(maxIdx, min)); - max = Math.max(min, Math.min(maxIdx, max)); - return { min: min, max: max }; - } - - function render() { - var n = commitCount(); - if (n <= 0) { - win.style.left = "0%"; - win.style.width = "100%"; - return; - } - var b = visibleBounds(); - var span = Math.max(1, n - 1); - var leftPct = (b.min / span) * 100; - var widthPct = ((b.max - b.min) / span) * 100; - // A minimum visible width keeps the handles grabbable when zoomed in - // tight on a single commit. - if (widthPct < 1.5) widthPct = 1.5; - if (leftPct + widthPct > 100) leftPct = 100 - widthPct; - win.style.left = leftPct + "%"; - win.style.width = widthPct + "%"; - } - - function setRange(newMin, newMax) { - var n = commitCount(); - if (n <= 0) return; - var maxIdx = n - 1; - var minRange = 1; // matches plugin `limits.x.minRange = 4` loosely; allow tighter via strip - newMin = Math.max(0, Math.min(maxIdx - minRange, newMin)); - newMax = Math.max(newMin + minRange, Math.min(maxIdx, newMax)); - chart.options.scales.x.min = newMin; - chart.options.scales.x.max = newMax; - // Track scope on the canvas so the toolbar slider stays consistent - // when the user later drags it. - if (canvas && canvas.__bench_state) { - canvas.__bench_state.scope = Math.round(newMax - newMin + 1); - } - // Re-derive what Chart.js renders against the new visible window. - // `rebuildVisibleAndUpdate` calls `chart.update("none")`, applies - // LTTB, and mirrors the new scope onto the toolbar slider, so the - // strip-driven pan/resize stays in lockstep with both the data - // density and the slider readout. - rebuildVisibleAndUpdate(card, chart, newMin, newMax, true); - render(); - } - - function pxToIndex(px, trackWidth) { - var n = commitCount(); - if (n <= 1 || trackWidth <= 0) return 0; - var pct = Math.max(0, Math.min(1, px / trackWidth)); - return pct * (n - 1); - } - - var dragState = null; - - function onPointerDown(e) { - if (e.button !== undefined && e.button !== 0) return; - var role = e.target.getAttribute && e.target.getAttribute("data-role"); - var rect = strip.getBoundingClientRect(); - var trackWidth = rect.width; - var b = visibleBounds(); - var idxAtCursor = pxToIndex(e.clientX - rect.left, trackWidth); - - var mode; - if (role === "range-handle-left") mode = "resize-left"; - else if (role === "range-handle-right") mode = "resize-right"; - else if (role === "range-window") mode = "pan"; - else { - // Click on bare track: jump the window so its centre lands at the - // cursor, then begin a pan drag. - var width = b.max - b.min; - var newMin = idxAtCursor - width / 2; - setRange(newMin, newMin + width); - b = visibleBounds(); - mode = "pan"; - } - dragState = { - mode: mode, - rect: rect, - startX: e.clientX, - startMin: b.min, - startMax: b.max, - pointerId: e.pointerId, - }; - try { strip.setPointerCapture(e.pointerId); } catch (err) {} - e.preventDefault(); - strip.classList.add("chart-range-strip--dragging"); - } - - function onPointerMove(e) { - if (!dragState) return; - var n = commitCount(); - if (n <= 1) return; - var trackWidth = dragState.rect.width; - var dxPx = e.clientX - dragState.startX; - var dxIdx = (dxPx / Math.max(1, trackWidth)) * (n - 1); - if (dragState.mode === "pan") { - setRange(dragState.startMin + dxIdx, dragState.startMax + dxIdx); - } else if (dragState.mode === "resize-left") { - setRange(dragState.startMin + dxIdx, dragState.startMax); - } else if (dragState.mode === "resize-right") { - setRange(dragState.startMin, dragState.startMax + dxIdx); - } - } - - function onPointerUp(e) { - if (!dragState) return; - try { strip.releasePointerCapture(dragState.pointerId); } catch (err) {} - dragState = null; - strip.classList.remove("chart-range-strip--dragging"); - } - - strip.addEventListener("pointerdown", onPointerDown); - strip.addEventListener("pointermove", onPointerMove); - strip.addEventListener("pointerup", onPointerUp); - strip.addEventListener("pointercancel", onPointerUp); - - // Expose the strip's render function so other code paths (toolbar - // slider, wheel-pan, the throttled LTTB rebuild) can keep the strip - // in lockstep without each having to know strip internals. The chart - // options' `onPan` / `onZoom` callbacks call this via the throttled - // rebuild rather than overriding them here, so LTTB and the strip - // refresh as one unit. - canvas.__bench_strip_render = render; - render(); - } - - // Cap the toolbar slider's `max` to the chart's full x-axis length. For a - // latest-100 virtual payload this is intentionally larger than the loaded - // point count, so "Show all" can expose the unloaded older range while the - // full-history fetch is warming. - function syncSliderBounds(card, commitCount) { - var slider = card.querySelector('[data-role="scope-slider"]'); - if (!slider) return; - var max = Math.max(5, commitCount); - slider.max = String(max); - // Pick a step that gives ~200 stops across the slider so dragging - // feels continuous regardless of history size. - var step = Math.max(1, Math.round(max / 200)); - slider.step = String(step); - var current = parseInt(slider.value, 10); - if (!Number.isFinite(current) || current > max) { - var def = defaultScopeForCard(card); - var seed = def === "all" ? max : Math.min(def, max); - slider.value = String(seed); - } - } - - // Wheel = horizontal pan. Chart.js zoom plugin doesn't support wheel-pan - // out of the box (wheel is always zoom in its config), so we attach a - // `wheel` listener that translates `deltaY`/`deltaX` into `chart.pan` and - // re-runs the rebuild after panning. - function attachWheelPan(canvas, chart, rebuild) { - if (canvas.__bench_wheel_attached) return; - canvas.__bench_wheel_attached = true; - canvas.addEventListener("wheel", function (e) { - // Treat horizontal-wheel-or-shift+wheel as horizontal pan; otherwise - // also pan on plain vertical wheel so trackpad scroll-up/down moves - // through commit history without needing modifier keys. - var dx = (Math.abs(e.deltaX) > Math.abs(e.deltaY)) ? e.deltaX : e.deltaY; - if (!dx) return; - e.preventDefault(); - // Browser wheel-down reports a positive delta. In Chart.js pan space, - // positive x moves the visible window toward older commits, while - // negative x moves back toward newer commits. - chart.pan({ x: dx * 0.5 }, undefined, "none"); - // `rebuild` recomputes LTTB on the new visible range AND, via the - // throttled wrapper, also calls `canvas.__bench_strip_render`. - rebuild(chart); - }, { passive: false }); - } - - // ----------------------------------------------------------------------- - // Recompute helpers driven by the per-chart toolbar. - // ----------------------------------------------------------------------- - // Invariant: when `currentRange` is supplied AND the chart is already - // panned away from the right edge, a scope change preserves the visible - // CENTER instead of snapping to the most recent N commits. With no - // `currentRange` (initial render) or a view that already covers - // everything / sits flush with the newest commit, anchor to the right — - // the right default at first load and after "show all". - function visibleRange(commitCount, scope, currentRange) { - if (commitCount <= 0) return { min: undefined, max: undefined }; - var maxIdx = commitCount - 1; - if (scope === "all" || !Number.isFinite(scope) || scope <= 0 || scope >= commitCount) { - return { min: 0, max: maxIdx }; - } - var width = scope; - var rightAnchored = { min: Math.max(0, maxIdx - (width - 1)), max: maxIdx }; - if (!currentRange) return rightAnchored; - var curMin = Number.isFinite(currentRange.min) ? currentRange.min : 0; - var curMax = Number.isFinite(currentRange.max) ? currentRange.max : maxIdx; - var coversAll = curMin <= 0 && curMax >= maxIdx; - // Half-commit tolerance: pan/zoom can leave fractional drift even when - // the user is effectively still flush with the newest commit. - var atRightEdge = curMax >= maxIdx - 0.5; - if (coversAll || atRightEdge) return rightAnchored; - var center = (curMin + curMax) / 2; - var halfWidth = (width - 1) / 2; - var newMin = Math.round(center - halfWidth); - var newMax = newMin + (width - 1); - if (newMin < 0) { - newMin = 0; - newMax = width - 1; - } else if (newMax > maxIdx) { - newMax = maxIdx; - newMin = maxIdx - (width - 1); - } - return { min: newMin, max: newMax }; - } - - function applyScope(card, scopeValue) { - var canvas = card.querySelector("canvas"); - var chart = canvas && canvas.__bench_chart; - if (!chart) return; - var commits = chart.data.labels.length; - var scope = scopeValue === "all" ? "all" : parseInt(scopeValue, 10); - canvas.__bench_state.scope = scope; - // Capture the chart's existing visible window BEFORE we overwrite it, - // so `visibleRange` can preserve the center when the user has panned - // away from the right edge. - var sx = chart.options.scales.x; - var currentRange = sx ? { min: sx.min, max: sx.max } : null; - var range = visibleRange(commits, scope, currentRange); - chart.options.scales.x.min = range.min; - chart.options.scales.x.max = range.max; - rebuildVisibleAndUpdate(card, chart, range.min, range.max, true); - syncToolbarUi(card, "scope", String(scopeValue)); - if (canvas.__bench_strip_render) canvas.__bench_strip_render(); - } - - // `userInitiated` defaults to true. Once set, the chart is "sticky" — the - // per-group Y apply pass skips it on subsequent group-level clicks, - // honouring the user's explicit per-card choice. The per-group toolbar - // passes `false` so it doesn't pollute the flag while broadcasting. - function applyY(card, yValue, userInitiated) { - var canvas = card.querySelector("canvas"); - var chart = canvas && canvas.__bench_chart; - if (!chart) return; - if (userInitiated !== false) { - canvas.__bench_y_user_set = true; - } - canvas.__bench_state.y = yValue; - chart.options.scales.y.type = yValue === "log" ? "logarithmic" : "linear"; - chart.options.scales.y.beginAtZero = yValue !== "log"; - chart.update("none"); - syncToolbarUi(card, "y", yValue); - } - - function syncToolbarUi(card, group, value) { - var attr = "data-" + group; - card.querySelectorAll(".toolbar-btn[" + attr + "]").forEach(function (b) { - b.classList.toggle("toolbar-btn--active", b.getAttribute(attr) === value); - }); - if (group === "scope") { - var slider = card.querySelector('[data-role="scope-slider"]'); - if (slider && /^\d+$/.test(value)) slider.value = value; - } - } - - function bindToolbar(card) { - var toolbar = card.querySelector(".toolbar--card"); - if (!toolbar || toolbar.__bench_bound) return; - toolbar.__bench_bound = true; - - toolbar.addEventListener("click", function (e) { - var btn = e.target.closest(".toolbar-btn"); - if (!btn || !toolbar.contains(btn)) return; - if (btn.hasAttribute("data-y")) applyY(card, btn.getAttribute("data-y")); - }); - - var slider = toolbar.querySelector('[data-role="scope-slider"]'); - if (slider) { - // `input` (continuous), throttled so dragging stays at ~60fps even on - // pages with dozens of charts. Last value still lands because - // `throttle` preserves the trailing call. - var throttled = throttle(function () { - applyScope(card, slider.value); - }, ZOOM_THROTTLE_MS); - slider.addEventListener("input", throttled); - } - } - - // ----------------------------------------------------------------------- - // Group hydration. Every landing group renders chart shells with versioned - // shard metadata. On intent or first open we fetch materialized latest-100 - // group shards through a small per-tab queue and construct each chart as - // soon as its shard arrives. - // ----------------------------------------------------------------------- - var hydrationActive = 0; - var hydrationQueue = []; - var fullHistoryActive = 0; - var fullHistoryQueue = []; - var groupOpenPriority = 0; - - function scheduleHydration(task, priority) { - var entry = { - task: task, - priority: priority || 0, - promise: null, - resolve: null, - reject: null, - }; - entry.promise = new Promise(function (resolve, reject) { - entry.resolve = resolve; - entry.reject = reject; - }); - hydrationQueue.push(entry); - drainHydrationQueue(); - return entry; - } - - function drainHydrationQueue() { - while (hydrationActive < HYDRATION_CONCURRENCY && hydrationQueue.length) { - hydrationQueue.sort(function (a, b) { return b.priority - a.priority; }); - var item = hydrationQueue.shift(); - hydrationActive++; - Promise.resolve() - .then(item.task) - .then( - function (value) { - hydrationActive--; - item.resolve(value); - drainHydrationQueue(); - }, - function (err) { - hydrationActive--; - item.reject(err); - drainHydrationQueue(); - } - ); - } - } - - function scheduleFullHistory(task, priority) { - var entry = { - task: task, - priority: priority || 0, - promise: null, - resolve: null, - reject: null, - }; - entry.promise = new Promise(function (resolve, reject) { - entry.resolve = resolve; - entry.reject = reject; - }); - fullHistoryQueue.push(entry); - drainFullHistoryQueue(); - return entry; - } - - function drainFullHistoryQueue() { - while (fullHistoryActive < FULL_HISTORY_CONCURRENCY && fullHistoryQueue.length) { - fullHistoryQueue.sort(function (a, b) { return b.priority - a.priority; }); - var item = fullHistoryQueue.shift(); - fullHistoryActive++; - Promise.resolve() - .then(item.task) - .then( - function (value) { - fullHistoryActive--; - item.resolve(value); - drainFullHistoryQueue(); - }, - function (err) { - fullHistoryActive--; - item.reject(err); - drainFullHistoryQueue(); - } - ); - } - } - - function priorityForGroupOpen(group) { - groupOpenPriority += GROUP_OPEN_PRIORITY_STEP; - group.__bench_group_priority = groupOpenPriority; - return groupOpenPriority; - } - - function fetchAndConstruct(card) { - var canvas = card.querySelector("canvas"); - if (!canvas) return Promise.resolve(); - if (canvas.__bench_chart) return Promise.resolve(); - if (constructChart(card)) { - bindToolbar(card); - } - return Promise.resolve(); - } - - function groupCards(group) { - return Array.prototype.slice.call( - group.querySelectorAll(".chart-card[data-chart-slug]") - ); - } - - function cardHasPayloadAvailable(card) { - var canvas = card.querySelector("canvas"); - if (!canvas) return true; - if (canvas.__bench_payload) return true; - var idx = card.getAttribute("data-chart-index"); - return idx != null && !!readInlinePayload(idx); - } - - function groupShardCount(group) { - var n = parseInt(group.getAttribute("data-group-shard-count") || "0", 10); - return Number.isFinite(n) && n > 0 ? n : 0; - } - - function groupShardPrefix(group) { - return group.getAttribute("data-group-shard-prefix") || ""; - } - - function groupShardUrl(group, index) { - var prefix = groupShardPrefix(group); - return prefix ? prefix + encodeURIComponent(String(index)) : ""; - } - - function groupHydrationState(group) { - if (!group.__bench_group_hydration) { - group.__bench_group_hydration = { - loaded: {}, - pending: {}, - entries: {}, - errors: {}, - }; - } - return group.__bench_group_hydration; - } - - function cardBySlug(group, slug) { - var cards = groupCards(group); - for (var i = 0; i < cards.length; i++) { - if (cards[i].getAttribute("data-chart-slug") === slug) return cards[i]; - } - return null; - } - - function showCardLoading(card, on) { - var existing = card.querySelector(".chart-loading"); - if (on) { - if (existing) return; - var el = document.createElement("div"); - el.className = "chart-loading"; - el.textContent = "loading…"; - card.appendChild(el); - } else if (existing) { - existing.remove(); - } - } - - function showCardError(card, msg) { - var existing = card.querySelector(".chart-error"); - if (existing) existing.remove(); - var el = document.createElement("div"); - el.className = "chart-error"; - el.textContent = msg; - card.appendChild(el); - setTimeout(function () { if (el.parentNode) el.remove(); }, 4000); - } - - function applyGroupShard(group, shard) { - if (!shard || !Array.isArray(shard.charts)) return; - shard.charts.forEach(function (payload) { - if (!payload || !payload.slug) return; - var card = cardBySlug(group, payload.slug); - var canvas = card && card.querySelector("canvas"); - if (!canvas) return; - if (!canvas.__bench_full_loaded) { - canvas.__bench_payload = normalizeChartPayload(payload); - canvas.__bench_payload_window = CHART_FETCH_N; - } - showCardLoading(card, false); - if (groupIsOpen(group)) fetchAndConstruct(card); - }); - } - - function fetchGroupShard(group, index, priority) { - var state = groupHydrationState(group); - if (state.loaded[index]) return Promise.resolve(); - if (state.pending[index]) { - if (state.entries[index] && priority) { - state.entries[index].priority = Math.max(state.entries[index].priority, priority); - drainHydrationQueue(); - } - return state.pending[index]; - } - var url = groupShardUrl(group, index); - if (!url) return Promise.resolve(); - var entry = scheduleHydration(function () { - return fetch(url, { headers: { "accept": "application/json" } }) - .then(function (r) { - if (r.status === 404) throw new Error("not found"); - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }) - .then(function (payload) { - state.loaded[index] = true; - state.errors[index] = null; - applyGroupShard(group, payload); - }); - }, priority); - state.entries[index] = entry; - state.pending[index] = entry.promise.catch(function (err) { - state.errors[index] = err; - if (index === 0) { - groupCards(group).forEach(function (card) { - if (!cardHasPayloadAvailable(card)) { - showCardLoading(card, false); - showCardError(card, "failed to load: " + (err.message || "unknown error")); - } - }); - } - }).then(function () { - state.pending[index] = null; - state.entries[index] = null; - }); - return state.pending[index]; - } - - function groupIsOpen(group) { - var details = group.querySelector("details.group-disclosure"); - return !details || details.open; - } - - function queueRemainingGroupShards(group, priority) { - var count = groupShardCount(group); - for (var i = 1; i < count; i++) fetchGroupShard(group, i, priority || 0); - } - - function queueGroupFullHistory(group, priority) { - groupCards(group).forEach(function (card) { - ensureFullHistory(card, priority || 0); - }); - } - - function hydrateGroupShardZero(group, showLoading, priority) { - if (showLoading) { - groupCards(group).forEach(function (card) { - if (!cardHasPayloadAvailable(card)) showCardLoading(card, true); - }); - } - return fetchGroupShard(group, 0, priority || (showLoading ? 1 : 0)).then(function () { - if (showLoading) { - groupCards(group).forEach(function (card) { - if (cardHasPayloadAvailable(card)) showCardLoading(card, false); - }); - } - }); - } - - function hydrateOpenGroup(disclosure) { - if (!disclosure || !disclosure.open) return; - var group = disclosure.closest(".group-details"); - if (!group) return; - var priority = priorityForGroupOpen(group); - hydrateGroupShardZero(group, true, priority + 20).then(function () { - queueRemainingGroupShards(group, priority + 10); - queueGroupFullHistory(group, priority); - }); - } - - function prefetchGroupOnIntent(group) { - fetchGroupShard(group, 0, 0); - } - - // ----------------------------------------------------------------------- - // Global filter bar wiring. - // - // Chips live in `.global-filter-bar`. Click a non-"all" chip to toggle - // that engine/format in/out of the active set; click "all" to clear the - // filter for that dimension. After every change we: - // 1. Re-paint the chips. - // 2. Walk every chart on the page and re-apply the filter (skipping - // series the user has explicitly overridden on that card). - // 3. Sync the URL with `history.replaceState` so a refresh / share - // preserves the view. - // ----------------------------------------------------------------------- - // Apply the layered filter on a single card. Layer order matches the - // resolution rule documented at the top of the file: - // 1. Per-card legend overrides (`canvas.__bench_overrides`) win. - // 2. Per-group filter (`section.__bench_group_filter`) hides next. - // 3. Global filter hides last. - // 4. Otherwise show. - // Used by every code path that mutates filter state (global chip clicks, - // per-group chip clicks, post-construction seeding). - function applyFiltersToChart(card) { - var canvas = card.querySelector("canvas"); - var chart = canvas && canvas.__bench_chart; - if (!chart) return; - var overrides = canvas.__bench_overrides || {}; - var section = card.closest(".group-details"); - var groupFilter = section && section.__bench_group_filter; - var datasets = chart.data.datasets || []; - for (var i = 0; i < datasets.length; i++) { - var ds = datasets[i]; - if (overrides[ds.label]) continue; - var hidden = false; - if (!seriesPassesGroupFilter(groupFilter, ds.label)) { - hidden = true; - } else if (!seriesPassesFilter(ds.benchMeta)) { - hidden = true; - } - // Use the dataset.hidden field directly so the legend stays in sync; - // setDatasetVisibility writes into a separate visibility map. - ds.hidden = hidden; - } - chart.update("none"); - } - - function applyGlobalFilterEverywhere() { - document.querySelectorAll(".chart-card[data-chart-index]").forEach(function (card) { - applyFiltersToChart(card); - }); - } - - function syncFilterChipsUi() { - var bar = document.querySelector('[data-role="global-filter-bar"]'); - if (!bar) return; - bar.querySelectorAll(".filter-chip").forEach(function (chip) { - var dim = chip.getAttribute("data-filter"); - var value = chip.getAttribute("data-value"); - var key = dim === "engine" ? "engines" : "formats"; - var list = globalFilter[key]; - var active; - if (value === "*") { - // The "all" chip is a one-shot reset, never a "current state" - // indicator — leave it inactive. Pressing it forces every other - // chip in the row back to active. - active = false; - } else { - active = list.indexOf(value) !== -1; - } - chip.classList.toggle("filter-chip--active", active); - chip.setAttribute("aria-pressed", active ? "true" : "false"); - }); - syncFilterBadge(); - } - - // Show a badge on the trigger that counts how many chips are *off* - // (i.e. how many things the global filter is hiding). Hidden when the - // filter is fully open, so it's noise-free in the resting state. - function syncFilterBadge() { - var trigger = document.querySelector('[data-role="filter-trigger"]'); - if (!trigger) return; - var hidden = - Math.max(0, filterUniverse.engines.length - globalFilter.engines.length) + - Math.max(0, filterUniverse.formats.length - globalFilter.formats.length); - var badge = trigger.querySelector('[data-role="filter-badge"]'); - if (hidden === 0) { - if (badge) badge.remove(); - return; - } - if (!badge) { - badge = document.createElement("span"); - badge.className = "filter-badge"; - badge.setAttribute("data-role", "filter-badge"); - trigger.appendChild(badge); - } - badge.textContent = String(hidden); - } - - function syncFilterUrl() { - if (!window.history || !window.history.replaceState) return; - var url = new URL(window.location.href); - // URL stays as an allowlist (`?engine=duckdb` = "show only duckdb"). We - // emit the param only when the active set is a strict subset of the - // universe; an all-active row leaves the URL clean. - syncDimensionUrl(url, "engine", "engines"); - syncDimensionUrl(url, "format", "formats"); - window.history.replaceState(null, "", url.toString()); - } - - function syncDimensionUrl(url, paramName, key) { - if (dimensionIsFiltered(key)) { - url.searchParams.set(paramName, globalFilter[key].join(",")); - } else { - url.searchParams.delete(paramName); - } - } - - // Toggle one chip independently. The "all" chip resets the dimension to - // every-chip-active; specific chips just flip their own active state. - function toggleFilterValue(dim, value) { - var key = dim === "engine" ? "engines" : "formats"; - if (value === "*") { - globalFilter[key] = filterUniverse[key].slice(); - return; - } - var list = globalFilter[key]; - var idx = list.indexOf(value); - if (idx === -1) { - list.push(value); - } else { - list.splice(idx, 1); - } - } - - // Toggle the dropdown panel open/closed. Click outside or press Escape - // to close. The panel is anchored under the trigger button via CSS. - function bindFilterDropdown() { - var bar = document.querySelector('[data-role="global-filter-bar"]'); - if (!bar) return; - var trigger = bar.querySelector('[data-role="filter-trigger"]'); - var panel = bar.querySelector('[data-role="filter-panel"]'); - if (!trigger || !panel) return; - - function setOpen(open) { - if (open) { - panel.removeAttribute("hidden"); - } else { - panel.setAttribute("hidden", ""); - } - trigger.setAttribute("aria-expanded", open ? "true" : "false"); - bar.classList.toggle("filter-dropdown--open", open); - } - trigger.addEventListener("click", function (e) { - e.stopPropagation(); - var isOpen = !panel.hasAttribute("hidden"); - setOpen(!isOpen); - }); - document.addEventListener("click", function (e) { - if (!bar.contains(e.target)) setOpen(false); - }); - document.addEventListener("keydown", function (e) { - if (e.key === "Escape") setOpen(false); - }); - } - - function initGlobalFilterBar() { - var bar = document.querySelector('[data-role="global-filter-bar"]'); - if (!bar) return; - bar.addEventListener("click", function (e) { - var chip = e.target.closest(".filter-chip"); - if (!chip || !bar.contains(chip)) return; - var dim = chip.getAttribute("data-filter"); - var value = chip.getAttribute("data-value"); - if (!dim || !value) return; - toggleFilterValue(dim, value); - syncFilterChipsUi(); - applyGlobalFilterEverywhere(); - syncFilterUrl(); - }); - bindFilterDropdown(); - syncFilterChipsUi(); - } - - // ----------------------------------------------------------------------- - // Per-group toolbar wiring. - // - // Each `.group-details` section carries a `[data-role="group-toolbar"]` - // with Y-axis buttons and a centered filter dropdown. State lives on the - // section node: - // section.__bench_group_filter = { hiddenSeries: [, ...] } - // section.__bench_group_y = "linear" | "log" | null - // section.__bench_known_series = {
` - // URLs that come from ingested COMMITS rows (the fixtures use - // 1111..., 2222..., 3333... so a regression that misrenders those - // links is still pinned). The hex match is `{7,40}` so an extended - // disambiguating short-SHA (git auto-extends to 8-12 chars when the - // 7-char prefix is ambiguous) is also caught. - s.add_filter( - concat!( - r#"class="site-footer-sha"[^>]*href="https://github\.com/vortex-data/vortex/commit/"#, - r#"[0-9a-f]{7,40}"[^>]*>[0-9a-f]{7,40}"#, - ), - r#"class="site-footer-sha" href="https://github.com/vortex-data/vortex/commit/" rel="noopener noreferrer" target="_blank">"#, - ); - s -} - -/// Lift a single chart slug from `/api/groups`, picking from a group whose -/// name matches `predicate`. Used by tests that need a real slug to drive -/// `/chart/{slug}` and `/api/chart/{slug}` round-trips. -pub(crate) async fn pick_chart_slug( - server: &Server, - predicate: impl Fn(&str) -> bool, -) -> Result { - let client = reqwest::Client::new(); - let groups: Value = client - .get(server.url("/api/groups")) - .send() - .await? - .json() - .await?; - groups["groups"] - .as_array() - .context("groups is array")? - .iter() - .find(|g| g["name"].as_str().is_some_and(&predicate)) - .and_then(|g| g["charts"].as_array()) - .and_then(|c| c.first()) - .and_then(|c| c["slug"].as_str()) - .map(str::to_string) - .context("matching chart slug") -} - -/// Lift a single group slug from `/api/groups`, picking the first group -/// whose name matches `predicate`. -pub(crate) async fn pick_group_slug( - server: &Server, - predicate: impl Fn(&str) -> bool, -) -> Result { - let client = reqwest::Client::new(); - let groups: Value = client - .get(server.url("/api/groups")) - .send() - .await? - .json() - .await?; - groups["groups"] - .as_array() - .context("groups is array")? - .iter() - .find(|g| g["name"].as_str().is_some_and(&predicate)) - .and_then(|g| g["slug"].as_str()) - .map(str::to_string) - .context("matching group slug") -} - -/// Look up a group entry by its `name` field inside an `/api/groups` -/// response. -pub(crate) fn group_by_name<'a>(groups: &'a Value, name: &str) -> Result<&'a Value> { - groups["groups"] - .as_array() - .context("groups is array")? - .iter() - .find(|g| g["name"].as_str() == Some(name)) - .with_context(|| format!("group {name:?} exists")) -} - -/// Fuzzy `f64` equality for test assertions. The summary rollups round-trip -/// through SQL so exact equality isn't safe even on integer-valued inputs. -pub(crate) fn assert_close(actual: f64, expected: f64) { - let delta = (actual - expected).abs(); - assert!( - delta < 0.000_001, - "expected {actual} to be close to {expected}" - ); -} - -/// Pull just the `
` substring of the -/// filter dropdown - its trigger button and the chip panel. Keeps the -/// snapshot focused on the chip markup and stable against changes elsewhere -/// on the page. -pub(crate) fn filter_bar_section(body: &str) -> String { - let needle = r#"
".to_string(); - }; - let tail = &body[start..]; - // The dropdown is `
...
`. - // We need to find the matching `
` for the outer wrapper. The - // simplest robust approach is to scan and balance. - let bytes = tail.as_bytes(); - let mut depth = 0usize; - let mut i = 0usize; - while i < bytes.len() { - if bytes[i] == b'<' { - if tail[i..].starts_with("") { - depth -= 1; - if depth == 0 { - return tail[..i + "
".len()].to_string(); - } - i += "".len(); - continue; - } - } - i += 1; - } - tail.to_string() -} - -/// Pull the `
` containing chips for one -/// dimension (`"engine"` or `"format"`). -pub(crate) fn filter_section(body: &str, dim: &str) -> String { - let bar = filter_bar_section(body); - let needle = format!(r#"data-filter="{dim}""#); - let Some(_) = bar.find(&needle) else { - return String::new(); - }; - // Walk back to the enclosing `
`. - let row_open = r#"
"#; - let row_close = "
"; - bar.split(row_open) - .find(|chunk| chunk.contains(&needle)) - .and_then(|chunk| chunk.split(row_close).next()) - .map(str::to_string) - .unwrap_or_default() -} - -/// Pull a single chip's opening tag for assertions. -pub(crate) fn extract_chip(section: &str, value: &str) -> String { - let needle = format!(r#"data-value="{value}""#); - let Some(idx) = section.find(&needle) else { - return String::new(); - }; - let head = §ion[..idx]; - let chip_start = head.rfind("').map(|p| p + 1).unwrap_or(tail.len()); - tail[..chip_end].to_string() -} diff --git a/benchmarks-website/server/tests/group_api.rs b/benchmarks-website/server/tests/group_api.rs deleted file mode 100644 index dbc33473c07..00000000000 --- a/benchmarks-website/server/tests/group_api.rs +++ /dev/null @@ -1,290 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Integration tests for `/group/{slug}` and `/api/group/{slug}` plus the -//! v2-compatible group summary contract on `/api/groups`. - -mod common; - -use anyhow::Context as _; -use anyhow::Result; -use serde_json::Value; - -use self::common::Server; -use self::common::assert_close; -use self::common::attr_value; -use self::common::group_by_name; -use self::common::insta_settings; -use self::common::pick_group_slug; -use self::common::seed; -use self::common::seed_long_history; - -#[tokio::test] -async fn group_page_snapshot() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let slug = pick_group_slug(&server, |s| s.starts_with("TPC-H")).await?; - - let resp = client - .get(server.url(&format!("/group/{slug}"))) - .send() - .await?; - assert_eq!(resp.status(), 200); - let body = resp.text().await?; - assert!( - !body.contains(r#"id="chart-data-0""#), - "group page should hydrate through materialized shards, not inline chart payloads" - ); - assert!( - body.contains(r#"data-artifact-generation="#) - && body.contains(r#"data-group-shard-prefix="#) - && body.contains(r#"open"#), - "group page should render an open shard-hydrated group shell" - ); - assert!( - body.contains(r#"class="toolbar toolbar--card""#), - "per-chart toolbar must be rendered on group page" - ); - insta_settings().bind(|| { - insta::assert_snapshot!("group_page_query", body); - }); - Ok(()) -} - -#[tokio::test] -async fn group_api_returns_charts() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let slug = pick_group_slug(&server, |s| s.starts_with("TPC-H")).await?; - - let client = reqwest::Client::new(); - let resp = client - .get(server.url(&format!("/api/group/{slug}"))) - .send() - .await?; - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await?; - let charts = body["charts"].as_array().context("charts is array")?; - assert!(!charts.is_empty(), "group must have at least one chart"); - let first = &charts[0]; - assert!(first["slug"].as_str().is_some(), "chart slug present"); - assert!(first["name"].as_str().is_some(), "chart name present"); - assert!( - first["commits"].as_array().is_some(), - "embedded chart commits" - ); - assert!( - first["series"].as_object().is_some(), - "embedded chart series" - ); - assert_eq!( - body["summary"]["type"].as_str(), - Some("queryBenchmark"), - "group API should include the server-computed summary" - ); - Ok(()) -} - -#[tokio::test] -async fn group_api_respects_commit_window() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let slug = pick_group_slug(&server, |s| s.starts_with("TPC-H")).await?; - - let client = reqwest::Client::new(); - let resp = client - .get(server.url(&format!("/api/group/{slug}?n=1"))) - .send() - .await?; - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await?; - let charts = body["charts"].as_array().context("charts is array")?; - assert!(!charts.is_empty(), "group must have at least one chart"); - for chart in charts { - let commits = chart["commits"].as_array().context("commits is array")?; - assert!( - commits.len() <= 1, - "group hydration should honor the requested commit window" - ); - assert!(chart["series"].as_object().is_some(), "series is present"); - } - Ok(()) -} - -#[tokio::test] -async fn group_shard_artifact_returns_bounded_chart_payloads() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let landing = client.get(server.url("/")).send().await?.text().await?; - let generation = attr_value(&landing, "data-artifact-generation") - .context("landing exposes artifact generation")?; - let group_slug = - attr_value(&landing, "data-group-slug").context("landing exposes group slug")?; - - let resp = client - .get(server.url(&format!( - "/api/artifacts/{generation}/groups/{group_slug}/shards/0" - ))) - .send() - .await?; - assert_eq!(resp.status(), 200); - let etag = resp - .headers() - .get(reqwest::header::ETAG) - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert!(!etag.is_empty(), "artifact response should carry an ETag"); - let vary = resp - .headers() - .get(reqwest::header::VARY) - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_ascii_lowercase(); - assert!( - vary.contains("accept-encoding"), - "artifact response should vary on Accept-Encoding" - ); - - let body: Value = resp.json().await?; - assert_eq!(body["window"].as_u64(), Some(100)); - assert_eq!(body["shard_index"].as_u64(), Some(0)); - assert!( - body["shard_count"].as_u64().unwrap_or_default() >= 1, - "shard count should be present" - ); - let charts = body["charts"].as_array().context("charts is array")?; - assert!(!charts.is_empty(), "first shard should include charts"); - assert!(charts.len() <= 8, "shards should carry at most 8 charts"); - for chart in charts { - let commits = chart["commits"].as_array().context("commits is array")?; - assert!( - commits.len() <= 100, - "artifact hydration should use the latest-100 window" - ); - assert!(chart["series"].as_object().is_some(), "series is present"); - } - Ok(()) -} - -#[tokio::test] -async fn group_shard_artifact_carries_chart_history_metadata() -> Result<()> { - let server = Server::start().await?; - seed_long_history(&server, 125).await?; - - let client = reqwest::Client::new(); - let landing = client.get(server.url("/")).send().await?.text().await?; - let generation = attr_value(&landing, "data-artifact-generation") - .context("landing exposes artifact generation")?; - let group_slug = - attr_value(&landing, "data-group-slug").context("landing exposes group slug")?; - - let body: Value = client - .get(server.url(&format!( - "/api/artifacts/{generation}/groups/{group_slug}/shards/0" - ))) - .send() - .await? - .json() - .await?; - let first_chart = body["charts"][0] - .as_object() - .context("first chart in shard")?; - assert_eq!( - first_chart["commits"].as_array().map(Vec::len), - Some(100), - "group shard remains a latest-100 payload" - ); - assert_eq!(first_chart["history"]["total_commits"].as_u64(), Some(125)); - assert_eq!(first_chart["history"]["start_index"].as_u64(), Some(25)); - assert_eq!(first_chart["history"]["loaded_commits"].as_u64(), Some(100)); - assert_eq!(first_chart["history"]["complete"].as_bool(), Some(false)); - Ok(()) -} - -#[tokio::test] -async fn unknown_group_shard_artifact_returns_404() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let resp = client - .get(server.url("/api/artifacts/not-a-generation/groups/not-a-group/shards/0")) - .send() - .await?; - assert_eq!(resp.status(), 404); - Ok(()) -} - -#[tokio::test] -async fn group_summaries_match_v2_contract() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let groups: Value = client - .get(server.url("/api/groups")) - .send() - .await? - .json() - .await?; - - let random_access = &group_by_name(&groups, "Random Access")?["summary"]; - assert_eq!(random_access["type"].as_str(), Some("randomAccess")); - let rankings = random_access["rankings"] - .as_array() - .context("random access rankings")?; - assert_eq!(rankings[0]["name"].as_str(), Some("vortex-file-compressed")); - assert_eq!(rankings[1]["name"].as_str(), Some("parquet")); - assert_close(rankings[1]["ratio"].as_f64().context("random ratio")?, 2.0); - - let compression = &group_by_name(&groups, "Compression")?["summary"]; - assert_eq!(compression["type"].as_str(), Some("compression")); - assert_close( - compression["compressRatio"] - .as_f64() - .context("compressRatio")?, - 2.0, - ); - assert_close( - compression["decompressRatio"] - .as_f64() - .context("decompressRatio")?, - 2.0, - ); - assert_eq!(compression["datasetCount"].as_u64(), Some(1)); - - let compression_size = &group_by_name(&groups, "Compression Size")?["summary"]; - assert_eq!(compression_size["type"].as_str(), Some("compressionSize")); - assert_close( - compression_size["meanRatio"] - .as_f64() - .context("meanRatio")?, - 0.5, - ); - assert_eq!(compression_size["datasetCount"].as_u64(), Some(1)); - - let query = &group_by_name(&groups, "TPC-H (NVMe) (SF=1)")?["summary"]; - assert_eq!(query["type"].as_str(), Some("queryBenchmark")); - let rankings = query["rankings"].as_array().context("query rankings")?; - assert_eq!( - rankings[0]["name"].as_str(), - Some("datafusion:vortex-file-compressed"), - "query summary should include v2's missing-series penalty" - ); - assert_eq!(rankings[1]["name"].as_str(), Some("duckdb:parquet")); - let first_score = rankings[0]["score"].as_f64().context("first score")?; - let second_score = rankings[1]["score"].as_f64().context("second score")?; - assert!( - first_score < second_score, - "lower query score should rank first" - ); - - Ok(()) -} diff --git a/benchmarks-website/server/tests/ingest.rs b/benchmarks-website/server/tests/ingest.rs deleted file mode 100644 index ba4af6e29b4..00000000000 --- a/benchmarks-website/server/tests/ingest.rs +++ /dev/null @@ -1,385 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Integration tests for `POST /api/ingest` — round-trips the bearer -//! check, the all-or-nothing transaction, the schema-version gate, and -//! the upsert path. - -use std::net::SocketAddr; - -use anyhow::Context as _; -use anyhow::Result; -use serde_json::Value; -use serde_json::json; -use tempfile::TempDir; -use tokio::net::TcpListener; -use tokio::task::JoinHandle; -use tokio::time::Duration; -use vortex_bench_server::app::AppState; -use vortex_bench_server::app::router; - -const TOKEN: &str = "test-bearer-token"; - -struct Server { - addr: SocketAddr, - _tmp: TempDir, - handle: JoinHandle<()>, -} - -impl Server { - async fn start() -> Result { - let tmp = TempDir::new()?; - let db_path = tmp.path().join("bench.duckdb"); - let state = AppState::open(&db_path, TOKEN.to_string())?; - let app = router(state); - - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - Ok(Self { - addr, - _tmp: tmp, - handle, - }) - } - - fn url(&self, path: &str) -> String { - format!("http://{}{}", self.addr, path) - } -} - -impl Drop for Server { - fn drop(&mut self) { - self.handle.abort(); - } -} - -async fn wait_for_groups(client: &reqwest::Client, server: &Server) -> Result { - let mut last_len = 0usize; - for _ in 0..100 { - let resp = client.get(server.url("/api/groups")).send().await?; - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await?; - last_len = body["groups"].as_array().context("groups is array")?.len(); - if last_len > 0 { - return Ok(body); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - anyhow::bail!("read model did not rebuild groups; last len {last_len}") -} - -fn fixture_envelope() -> Value { - let raw = include_str!("../fixtures/envelope.json"); - serde_json::from_str(raw).expect("fixture envelope is valid JSON") -} - -#[tokio::test] -async fn happy_path_then_idempotent_reingest() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - let envelope = fixture_envelope(); - - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 200, "first ingest should be 200"); - let body: Value = resp.json().await?; - assert_eq!(body["inserted"].as_u64(), Some(5)); - assert_eq!(body["updated"].as_u64(), Some(0)); - - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 200, "second ingest should be 200"); - let body: Value = resp.json().await?; - assert_eq!(body["inserted"].as_u64(), Some(0), "no new rows on re-emit"); - assert!( - body["updated"].as_u64().context("updated is u64")? > 0, - "re-emit must report at least one updated row" - ); - Ok(()) -} - -#[tokio::test] -async fn accepts_payloads_above_axum_default_body_limit() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - let mut envelope = fixture_envelope(); - envelope["commit"]["message"] = json!("x".repeat(3 * 1024 * 1024)); - - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 200); - Ok(()) -} - -#[tokio::test] -async fn missing_bearer_is_unauthorized() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - let envelope = fixture_envelope(); - - let resp = client - .post(server.url("/api/ingest")) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 401); - Ok(()) -} - -#[tokio::test] -async fn wrong_bearer_is_unauthorized() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - let envelope = fixture_envelope(); - - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth("not-the-real-token") - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 401); - Ok(()) -} - -#[tokio::test] -async fn unknown_kind_is_400() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let envelope = json!({ - "run_meta": { - "benchmark_id": "fixture", - "schema_version": 1, - "started_at": "2026-04-25T00:00:00Z" - }, - "commit": { - "sha": "0123456789abcdef0123456789abcdef01234567", - "timestamp": "2026-04-25T00:00:00Z", - "message": "x", "author_name": "x", "author_email": "x@x", - "committer_name": "x", "committer_email": "x@x", - "tree_sha": "fedcba9876543210fedcba9876543210fedcba98", - "url": "https://example.com" - }, - "records": [ - { "kind": "made_up_kind", "commit_sha": "0123456789abcdef0123456789abcdef01234567" } - ] - }); - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 400); - Ok(()) -} - -#[tokio::test] -async fn unknown_field_is_400() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let mut envelope = fixture_envelope(); - envelope["records"][0]["surprise_field"] = json!("oops"); - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 400); - Ok(()) -} - -#[tokio::test] -async fn schema_version_too_new_is_409() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let mut envelope = fixture_envelope(); - envelope["run_meta"]["schema_version"] = json!(99); - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 409); - Ok(()) -} - -#[tokio::test] -async fn invalid_storage_is_400_record_error() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let mut envelope = fixture_envelope(); - envelope["records"][0]["storage"] = json!("gcs"); - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&envelope) - .send() - .await?; - assert_eq!(resp.status(), 400); - let body: Value = resp.json().await?; - assert_eq!(body["record_index"], json!(0)); - Ok(()) -} - -#[tokio::test] -async fn health_reports_after_ingest() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - // Pre-ingest: counts are zero. - let resp = client.get(server.url("/health")).send().await?; - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await?; - assert_eq!(body["status"], "ok"); - assert_eq!(body["schema_version"], 1); - assert_eq!(body["row_counts"]["commits"], 0); - assert!(body["latest_commit_timestamp"].is_null()); - - // Ingest, then re-check. - client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&fixture_envelope()) - .send() - .await?; - - let resp = client.get(server.url("/health")).send().await?; - let body: Value = resp.json().await?; - assert_eq!(body["row_counts"]["commits"], 1); - assert_eq!(body["row_counts"]["query_measurements"], 1); - assert_eq!(body["row_counts"]["compression_times"], 1); - assert_eq!(body["row_counts"]["compression_sizes"], 1); - assert_eq!(body["row_counts"]["random_access_times"], 1); - assert_eq!(body["row_counts"]["vector_search_runs"], 1); - assert!(!body["latest_commit_timestamp"].is_null()); - Ok(()) -} - -#[tokio::test] -async fn read_routes_serve_after_ingest() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&fixture_envelope()) - .send() - .await?; - - let body = wait_for_groups(&client, &server).await?; - let groups = body["groups"].as_array().context("groups is array")?; - assert!( - !groups.is_empty(), - "groups should not be empty after ingest" - ); - - // Pick the first chart slug and round-trip it. - let first_chart = groups - .iter() - .find_map(|g| g["charts"].as_array().and_then(|c| c.first())) - .context("at least one chart")?; - let slug = first_chart["slug"] - .as_str() - .context("slug is a string")? - .to_string(); - - let resp = client - .get(server.url(&format!("/api/chart/{slug}"))) - .send() - .await?; - assert_eq!(resp.status(), 200, "chart {slug} should resolve"); - let body: Value = resp.json().await?; - assert!(body["display_name"].is_string()); - assert!(body["unit_kind"].is_string()); - assert!(body["commits"].is_array()); - assert_eq!( - body["commits"] - .as_array() - .context("commits is array")? - .len(), - 1 - ); - assert!(body["series"].is_object()); - Ok(()) -} - -#[tokio::test] -async fn unknown_slug_is_404() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let resp = client - .get(server.url("/api/chart/qm.aGVsbG8")) - .send() - .await?; - // Either 400 (couldn't decode JSON) or 404 (decoded but no rows). Both are - // acceptable per the contract; we just need it to not be a 500. - assert!( - resp.status() == 400 || resp.status() == 404, - "got {}", - resp.status() - ); - Ok(()) -} - -/// Reads are served from the materialized read model. Ingest schedules a -/// background rebuild and keeps the old generation live until the new one is -/// ready. -#[tokio::test] -async fn read_model_rebuilds_after_ingest() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - // Warm the cache against an empty DB. - let resp = client.get(server.url("/api/groups")).send().await?; - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await?; - assert_eq!( - body["groups"].as_array().context("groups is array")?.len(), - 0, - "groups should be empty before any ingest" - ); - - // Ingest some data. The handler invalidates fallback caches and schedules - // the materialized read-model rebuild after the commit. - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(&fixture_envelope()) - .send() - .await?; - assert_eq!(resp.status(), 200, "ingest should succeed"); - - // The old empty generation may be served briefly, but the rebuilt - // generation must become visible shortly after ingest. - let body = wait_for_groups(&client, &server).await?; - let groups = body["groups"].as_array().context("groups is array")?; - assert!( - !groups.is_empty(), - "groups must repopulate after the read-model rebuild" - ); - Ok(()) -} diff --git a/benchmarks-website/server/tests/landing.rs b/benchmarks-website/server/tests/landing.rs deleted file mode 100644 index 213b92576c9..00000000000 --- a/benchmarks-website/server/tests/landing.rs +++ /dev/null @@ -1,518 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Integration tests for the landing page (`GET /`). - -mod common; - -use anyhow::Context as _; -use anyhow::Result; -use serde_json::Value; - -use self::common::Server; -use self::common::attr_value; -use self::common::extract_chip; -use self::common::filter_bar_section; -use self::common::filter_section; -use self::common::insta_settings; -use self::common::pick_chart_slug; -use self::common::pick_group_slug; -use self::common::seed; -use self::common::seed_long_history; - -#[tokio::test] -async fn landing_page_snapshot() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let resp = client.get(server.url("/")).send().await?; - assert_eq!(resp.status(), 200); - let content_type = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - content_type.starts_with("text/html"), - "expected text/html, got {content_type:?}" - ); - let body = resp.text().await?; - - // Canvas shells render immediately, but chart data comes from - // versioned group shard artifacts instead of inline JSON. - assert!( - body.contains("" - ); - assert!( - !body.contains(r#"id="chart-data-0""#), - "landing page should not inline chart payloads" - ); - assert!( - body.contains(r#"data-chart-slug="#), - "every chart card carries data-chart-slug for the lazy-fetch path" - ); - assert!( - body.contains(r#"data-group-slug="#), - "every group carries data-group-slug as stable metadata" - ); - assert!( - body.contains(r#"data-artifact-generation="#) - && body.contains(r#"data-group-shard-count="#) - && body.contains(r#"data-group-shard-prefix="#), - "every group should carry versioned shard hydration metadata" - ); - assert!( - attr_value(&body, "data-artifact-generation").is_some_and(|v| !v.is_empty()), - "artifact generation should be non-empty" - ); - assert!( - !body.contains(r#"id="group-search""#), - "landing page should not render the old group search bar" - ); - assert!( - body.contains(r#"class="sticky-header""#), - "landing page should render the v2-style top navbar" - ); - assert!( - body.contains(r#"data-action="expand-all""#) - && body.contains(r#"data-action="collapse-all""#), - "navbar should expose expand/collapse controls" - ); - assert!( - body.contains(r#"data-role="theme-toggle""#), - "navbar should expose a theme toggle" - ); - assert!( - body.contains(r#"class="btn-icon""#) - || body.contains(r#"class="btn-icon theme-icon theme-icon-light""#), - "navbar controls should render icons" - ); - assert!( - body.contains(r#"Vortex_Black_NoBG.png"#) && body.contains(r#"Vortex_White_NoBG.png"#), - "navbar should render the Vortex logo assets" - ); - assert!( - body.contains("⚡") && body.contains("📤") && body.contains("⬇️") && body.contains("📊"), - "summaries should render the v2 summary icons" - ); - - insta_settings().bind(|| { - insta::assert_snapshot!("landing_page", body); - }); - Ok(()) -} - -/// All group disclosures render closed by default — the user picks which -/// to expand. Chart payloads are intentionally not inlined; the disclosure -/// carries shard metadata so JS can fetch the materialized latest-100 -/// artifact on intent/open. -#[tokio::test] -async fn details_all_groups_closed_by_default() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - - let opens: Vec<_> = body - .match_indices(r#"
').map(|p| i + p).unwrap_or(i); - body[i..=tag_end].contains(" open") - }) - .collect(); - assert!(!opens.is_empty(), "landing page must render
"); - for (i, is_open) in opens.iter().enumerate() { - assert!(!is_open, "group #{i} must be closed by default"); - } - assert!( - !body.contains(r#"id="chart-data-0""#), - "landing page should hydrate charts from materialized artifacts", - ); - assert!( - body.contains(r#"data-group-shard-count="#), - "closed groups should still carry shard metadata", - ); - Ok(()) -} - -#[tokio::test] -async fn collapsed_groups_still_show_summaries() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - - let mut found_visible_summary = false; - for (group_start, _) in body.match_indices(r#"
') - .map(|p| details_start + p) - .context("details tag closes")?; - let is_open = body[details_start..=details_tag_end].contains(" open"); - if is_open { - continue; - } - - let summary_end = body[details_start..] - .find("
") - .map(|p| details_start + p) - .context("disclosure closes")?; - let chart_grid_start = body[summary_end..] - .find(r#"
"#) - .map(|p| summary_end + p) - .context("details contains chart grid")?; - let visible_region = &body[summary_end..chart_grid_start]; - if visible_region.contains(r#"class="benchmark-scores-summary""#) { - found_visible_summary = true; - break; - } - } - - assert!( - found_visible_summary, - "at least one closed group should render its score summary before the hidden chart grid" - ); - Ok(()) -} - -/// Every `.chart-card` carries a compact `.toolbar.toolbar--card` so the user -/// has per-chart controls. There is no page-level toolbar, no preset scope -/// button row, and no abs/rel mode toggle. -#[tokio::test] -async fn chart_card_carries_per_chart_toolbar() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - - let card_count = body.matches(r#"
0, "landing page must render chart cards"); - assert_eq!( - toolbar_count, card_count, - "every chart-card must contain a toolbar--card ({card_count} cards / {toolbar_count} toolbars)" - ); - assert_eq!( - strip_count, card_count, - "every chart-card must carry a range-strip below the canvas \ - ({card_count} cards / {strip_count} strips)" - ); - assert!( - body.contains(r#"data-role="range-window""#) - && body.contains(r#"data-role="range-handle-left""#) - && body.contains(r#"data-role="range-handle-right""#), - "range-strip must include a draggable window and two resize handles" - ); - assert!( - !body.contains(r#"data-mode="#), - "abs/rel mode buttons should not render" - ); - assert!( - !body.contains(r#"data-scope="#), - "preset scope buttons should not render; use the slider instead" - ); - assert!( - body.contains(r#"data-role="scope-slider""#), - "scope slider should remain available" - ); - assert!( - !body.contains(r#"scope-slider-label"#), - "scope value labels should not add repeated numbers to every card" - ); - - // Same invariant on /chart/{slug}. - let slug = pick_chart_slug(&server, |s| s.starts_with("TPC-H")).await?; - let body = client - .get(server.url(&format!("/chart/{slug}"))) - .send() - .await? - .text() - .await?; - assert!( - body.contains(r#"class="toolbar toolbar--card""#), - "chart page must carry a per-chart toolbar" - ); - assert!(!body.contains(r#"data-mode="#)); - assert!(!body.contains(r#"data-scope="#)); - assert!(body.contains(r#"data-role="scope-slider""#)); - assert!(!body.contains(r#"scope-slider-label"#)); - - // Same invariant on /group/{slug}. - let group_slug = pick_group_slug(&server, |s| s.starts_with("TPC-H")).await?; - let body = client - .get(server.url(&format!("/group/{group_slug}"))) - .send() - .await? - .text() - .await?; - assert!( - body.contains(r#"class="toolbar toolbar--card""#), - "group page must carry per-chart toolbars" - ); - assert!(!body.contains(r#"data-mode="#)); - assert!(!body.contains(r#"data-scope="#)); - assert!(body.contains(r#"data-role="scope-slider""#)); - assert!(!body.contains(r#"scope-slider-label"#)); - Ok(()) -} - -/// Landing-page `
` summaries appear in the canonical v2 order: the -/// fixture seeds Random Access, Compression, Compression Size, TPC-H, and a -/// vector-search group. The first three are in `api::GROUP_ORDER` in the -/// expected positions; TPC-H follows; the unknown vector-search group sorts -/// last (alphabetical fallback after the listed names). -#[tokio::test] -async fn landing_groups_render_in_v2_order() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - - // Extract group names in render order from the `data-group-name=` attrs. - let mut names = Vec::new(); - for window in body.split("data-group-name=\"").skip(1) { - if let Some(end) = window.find('"') { - names.push(window[..end].to_string()); - } - } - let expected = [ - "Random Access", - "Compression", - "Compression Size", - "TPC-H (NVMe) (SF=1)", - "cohere-large-10m / partitioned", - ]; - assert_eq!(names, expected, "v2 ordering"); - Ok(()) -} - -#[tokio::test] -async fn empty_landing_page_renders() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let resp = client.get(server.url("/")).send().await?; - assert_eq!(resp.status(), 200); - let body = resp.text().await?; - assert!(body.contains("No data ingested yet")); - Ok(()) -} - -/// Landing page renders the global filter dropdown inside the sticky -/// header, with chip rows for engine and format sourced from the seeded -/// data — no hard-coding. -#[tokio::test] -async fn landing_page_renders_global_filter_bar() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - - // The dropdown lives inside the sticky header so it stays on-screen - // while the user scrolls. - let header_chunk = body - .split(r#"class="sticky-header""#) - .nth(1) - .and_then(|s| s.split("").next()) - .context("sticky header chunk")?; - assert!( - header_chunk.contains(r#"data-role="global-filter-bar""#), - "filter dropdown must live inside the sticky header" - ); - assert!(header_chunk.contains(r#"data-role="filter-trigger""#)); - assert!(header_chunk.contains(r#"data-role="filter-panel""#)); - assert!(header_chunk.contains(r#"data-filter="engine""#)); - assert!(header_chunk.contains(r#"data-filter="format""#)); - // Engines + formats from the seed fixture must appear as chips. - assert!(body.contains(r#"data-value="datafusion""#)); - assert!(body.contains(r#"data-value="duckdb""#)); - assert!(body.contains(r#"data-value="vortex-file-compressed""#)); - assert!(body.contains(r#"data-value="parquet""#)); - // Both rows have an "all" reset chip. - assert!(body.matches(r#"data-value="*""#).count() >= 2); - // The "all" chip is now a one-shot reset and is never rendered active — - // active chips reflect the visible engine/format set. - assert!( - !body.contains(r#"class="filter-chip filter-chip--all filter-chip--active""#), - "the 'all' chip should never start active" - ); - // No filter applied by default → every specific chip is active. - let engine_section = filter_section(&body, "engine"); - for engine in ["datafusion", "duckdb"] { - assert!( - extract_chip(&engine_section, engine).contains("filter-chip--active"), - "engine chip {engine} should be active when no filter is applied" - ); - } - // No badge on the trigger when nothing is hidden. - assert!( - !body.contains(r#"data-role="filter-badge""#), - "filter badge should be absent when no chips are off" - ); - // Embedded filter state JSON for the client to pick up. - assert!(body.contains(r#"id="bench-filter-state""#)); - - insta_settings().bind(|| { - insta::assert_snapshot!("landing_page_filter_bar", filter_bar_section(&body)); - }); - Ok(()) -} - -/// Landing page honours `?engine=`/`?format=` and reflects them as the -/// active chip set + initial filter-state JSON, so a refresh preserves view. -#[tokio::test] -async fn landing_page_honours_filter_query_params() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client - .get(server.url("/?engine=duckdb&format=vortex-file-compressed")) - .send() - .await? - .text() - .await?; - - assert!( - body.contains(r#"{"engines":["duckdb"],"formats":["vortex-file-compressed"]}"#), - "filter state JSON should reflect query params" - ); - let engine_section = filter_section(&body, "engine"); - assert!( - engine_section.contains(r#"data-value="duckdb""#) - && extract_chip(&engine_section, "duckdb").contains("filter-chip--active"), - "duckdb chip should be active" - ); - assert!( - !extract_chip(&engine_section, "datafusion").contains("filter-chip--active"), - "datafusion chip should NOT be active when engine=duckdb" - ); - assert!( - !extract_chip(&engine_section, "*").contains("filter-chip--active"), - "the 'all' chip is a reset, never active" - ); - // Trigger should show a badge counting the off chips (1 engine + 1 format). - assert!( - body.contains(r#"data-role="filter-badge""#), - "trigger should render a badge when chips are filtered off" - ); - Ok(()) -} - -/// The landing page does not inline chart JSON. Its first materialized shard -/// caps chart payloads at 100 commits regardless of `?n=`; power users get -/// full history via the explicit `/api/chart/{slug}?n=all` refetch. -#[tokio::test] -async fn landing_first_group_shard_caps_commits() -> Result<()> { - // 101 commits is the smallest fixture above the 100-commit artifact cap, so the - // cap actually kicks in. `seed_long_history` only seeds the Random-Access - // group; with the canonical group ordering Random Access sorts first. - let server = Server::start().await?; - seed_long_history(&server, 101).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - assert!( - !body.contains(r#"id="chart-data-0""#), - "landing page should not inline chart JSON" - ); - - let generation = attr_value(&body, "data-artifact-generation") - .context("landing exposes artifact generation")?; - let group_slug = attr_value(&body, "data-group-slug").context("landing exposes group slug")?; - let shard: Value = client - .get(server.url(&format!( - "/api/artifacts/{generation}/groups/{group_slug}/shards/0" - ))) - .send() - .await? - .json() - .await?; - let commits = shard["charts"][0]["commits"] - .as_array() - .context("shard chart commits array")?; - assert!( - commits.len() <= 100, - "landing shard must cap commits at 100, \ - got {}", - commits.len(), - ); - // Sanity check: the cap actually fired on this fixture (≥ 100 commits - // seeded). Without this we'd silently regress to "always small fixture". - assert_eq!( - commits.len(), - 100, - "with 101 seeded commits the shard payload should be exactly the \ - 100-commit cap; got {}", - commits.len(), - ); - - // ?n=all on the URL still parses without panicking and still leaves the - // landing page as shell-only metadata. - let body_all = client - .get(server.url("/?n=all")) - .send() - .await? - .text() - .await?; - assert!( - !body_all.contains(r#"id="chart-data-0""#), - "?n=all on the landing page must not inline full-history chart data" - ); - Ok(()) -} - -/// Sanity smoke test: round-trip every chart slug `/api/groups` returns -/// through `/chart/{slug}` to make sure each slug shape's HTML route is -/// wired up. -#[tokio::test] -async fn chart_page_round_trips_every_slug() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let groups: Value = client - .get(server.url("/api/groups")) - .send() - .await? - .json() - .await?; - let slugs: Vec = groups["groups"] - .as_array() - .context("groups is array")? - .iter() - .flat_map(|g| g["charts"].as_array().cloned().unwrap_or_default()) - .filter_map(|c| c["slug"].as_str().map(str::to_string)) - .collect(); - anyhow::ensure!(!slugs.is_empty(), "expected at least one chart slug"); - - for slug in &slugs { - let resp = client - .get(server.url(&format!("/chart/{slug}"))) - .send() - .await?; - assert_eq!( - resp.status(), - 200, - "chart page for slug {slug} should be 200" - ); - let body = resp.text().await?; - assert!( - body.contains(r#"id="chart-data-0""#), - "missing inline chart data for slug {slug}" - ); - } - Ok(()) -} diff --git a/benchmarks-website/server/tests/permalinks.rs b/benchmarks-website/server/tests/permalinks.rs deleted file mode 100644 index 3b8b3919119..00000000000 --- a/benchmarks-website/server/tests/permalinks.rs +++ /dev/null @@ -1,158 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Integration tests for `/chart/{slug}` and `/group/{slug}` permalink -//! behaviour: full-history payloads, embedded filter state, 404s on -//! unknown slugs. - -mod common; - -use anyhow::Context as _; -use anyhow::Result; - -use self::common::Server; -use self::common::attr_value; -use self::common::extract_chart_data; -use self::common::pick_chart_slug; -use self::common::pick_group_slug; -use self::common::seed; -use self::common::seed_long_history; - -/// `/chart/{slug}` defaults to the materialized latest-100 window and -/// upgrades to full raw history only with `?n=all`. `/group/{slug}` renders -/// shell markup and hydrates the same latest-100 materialized shards as the -/// landing page. -#[tokio::test] -async fn permalink_pages_default_to_latest_100_and_opt_into_full_history() -> Result<()> { - let server = Server::start().await?; - seed_long_history(&server, 101).await?; - - let chart_slug = pick_chart_slug(&server, |s| s == "Random Access").await?; - let group_slug = pick_group_slug(&server, |s| s == "Random Access").await?; - let client = reqwest::Client::new(); - - let chart_body = client - .get(server.url(&format!("/chart/{chart_slug}"))) - .send() - .await? - .text() - .await?; - let chart_payload = - extract_chart_data(&chart_body, 0).context("chart inline payload present")?; - assert_eq!( - chart_payload["commits"] - .as_array() - .context("commits is array")? - .len(), - 100, - "/chart permalink should default to the latest-100 materialized window", - ); - - let chart_all_body = client - .get(server.url(&format!("/chart/{chart_slug}?n=all"))) - .send() - .await? - .text() - .await?; - let chart_all_payload = - extract_chart_data(&chart_all_body, 0).context("chart all inline payload present")?; - assert_eq!( - chart_all_payload["commits"] - .as_array() - .context("all commits is array")? - .len(), - 101, - "/chart?n=all should inline the full raw history", - ); - - let group_body = client - .get(server.url(&format!("/group/{group_slug}"))) - .send() - .await? - .text() - .await?; - assert!( - !group_body.contains(r#"id="chart-data-0""#), - "/group permalink should not inline chart payloads" - ); - let generation = attr_value(&group_body, "data-artifact-generation") - .context("group page exposes artifact generation")?; - let shard_prefix = attr_value(&group_body, "data-group-shard-prefix") - .context("group page exposes shard prefix")?; - let shard_path = shard_prefix - .strip_prefix('/') - .map(|s| format!("/{s}0")) - .context("absolute shard prefix")?; - let shard: serde_json::Value = client - .get(server.url(&shard_path)) - .send() - .await? - .json() - .await?; - assert_eq!( - shard["charts"][0]["commits"] - .as_array() - .context("shard commits is array")? - .len(), - 100, - "/group permalink should hydrate the latest-100 shard", - ); - assert!( - shard_path.contains(&generation), - "group shard URL should be versioned by generation" - ); - - Ok(()) -} - -/// Permalink pages render the same filter dropdown in the navbar (so the -/// user can adjust visibility from any page) and embed the filter-state -/// JSON so chart-init.js applies the filter on hydration. -#[tokio::test] -async fn permalink_pages_embed_filter_state() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let chart_slug = pick_chart_slug(&server, |s| s.starts_with("TPC-H")).await?; - let group_slug = pick_group_slug(&server, |s| s.starts_with("TPC-H")).await?; - - let chart_body = client - .get(server.url(&format!("/chart/{chart_slug}?engine=duckdb&format=parquet"))) - .send() - .await? - .text() - .await?; - assert!( - chart_body.contains(r#"id="bench-filter-state""#), - "chart permalink must embed filter state" - ); - assert!( - chart_body.contains(r#"{"engines":["duckdb"],"formats":["parquet"]}"#), - "chart permalink must echo the query-param filter state" - ); - - let group_body = client - .get(server.url(&format!("/group/{group_slug}?engine=duckdb"))) - .send() - .await? - .text() - .await?; - assert!( - group_body.contains(r#"{"engines":["duckdb"],"formats":[]}"#), - "group permalink must echo the query-param filter state" - ); - Ok(()) -} - -#[tokio::test] -async fn unknown_slug_renders_404() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let resp = client.get(server.url("/chart/qm.aGVsbG8")).send().await?; - assert_eq!(resp.status(), 404); - let body = resp.text().await?; - assert!(body.contains("chart not found")); - Ok(()) -} diff --git a/benchmarks-website/server/tests/snapshots/chart_page_query.snap b/benchmarks-website/server/tests/snapshots/chart_page_query.snap deleted file mode 100644 index 7c576b55234..00000000000 --- a/benchmarks-website/server/tests/snapshots/chart_page_query.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: benchmarks-website/server/tests/chart_api.rs -assertion_line: 52 -expression: body ---- -tpch sf=1 Q1 [nvme] - Vortex Benchmarks

unit: ns · 2 series · 3 commits

Show
Y
build
diff --git a/benchmarks-website/server/tests/snapshots/group_page_query.snap b/benchmarks-website/server/tests/snapshots/group_page_query.snap deleted file mode 100644 index a1716e15501..00000000000 --- a/benchmarks-website/server/tests/snapshots/group_page_query.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: benchmarks-website/server/tests/group_api.rs -assertion_line: 51 -expression: body ---- -TPC-H (NVMe) (SF=1) - Vortex Benchmarks
TPC-H (NVMe) (SF=1)2 charts

Performance Summary

#1datafusion:vortex-file-compressed1.11x1.80 ms
#2duckdb:parquet1.60x900.00 us
Geomean of query time ratio to fastest (lower is better)
Y

Q1

Show
Y

Q2

Show
Y
build
diff --git a/benchmarks-website/server/tests/snapshots/landing_page.snap b/benchmarks-website/server/tests/snapshots/landing_page.snap deleted file mode 100644 index 8337ed6dc05..00000000000 --- a/benchmarks-website/server/tests/snapshots/landing_page.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: benchmarks-website/server/tests/landing.rs -assertion_line: 102 -expression: body ---- -Vortex Benchmarks
Random Access1 chart

Random Access Performance

#1vortex-file-compressed100.50 us1.00x
#2parquet201.00 us2.00x
Random access time | Ratio to fastest (lower is better)
Y

taxi

Show
Y
Compression1 chart

Compression Throughput vs Parquet

Write Speed (Compression)2.00x
📤Scan Speed (Decompression)2.00x
Inverse geomean of Vortex/Parquet ratios (higher is better)
Y

tpch-lineitem

Show
Y
Compression Size1 chart

Compression Size Summary

⬇️Min Size Ratio0.50x
📊Mean Size Ratio0.50x
⬆️Max Size Ratio0.50x
Geomean of Vortex/Parquet size ratios (lower is better)
Y

tpch-lineitem

Show
Y
TPC-H (NVMe) (SF=1)2 charts

Performance Summary

#1datafusion:vortex-file-compressed1.11x1.80 ms
#2duckdb:parquet1.60x900.00 us
Geomean of query time ratio to fastest (lower is better)
Y

Q1

Show
Y

Q2

Show
Y
cohere-large-10m / partitioned1 chart
Y

threshold=0.75

Show
Y
build
diff --git a/benchmarks-website/server/tests/snapshots/landing_page_filter_bar.snap b/benchmarks-website/server/tests/snapshots/landing_page_filter_bar.snap deleted file mode 100644 index 1a995f8a01f..00000000000 --- a/benchmarks-website/server/tests/snapshots/landing_page_filter_bar.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: benchmarks-website/server/tests/web_ui.rs -expression: filter_bar_section(&body) ---- -
diff --git a/benchmarks-website/server/tests/static_assets.rs b/benchmarks-website/server/tests/static_assets.rs deleted file mode 100644 index 8533419a416..00000000000 --- a/benchmarks-website/server/tests/static_assets.rs +++ /dev/null @@ -1,211 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Integration tests for the bundled `/static/...` asset routes plus the -//! response compression layer. - -mod common; - -use std::io::Read as _; - -use anyhow::Result; -use flate2::read::GzDecoder; - -use self::common::Server; -use self::common::seed; - -#[tokio::test] -async fn static_assets_are_served() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - for (path, ct_prefix) in [ - ("/static/chart.umd.js", "application/javascript"), - ( - "/static/chartjs-plugin-zoom.umd.min.js", - "application/javascript", - ), - ("/static/chart-init.js", "application/javascript"), - ("/static/style.css", "text/css"), - ("/static/icon-light.png", "image/png"), - ("/static/icon-dark.png", "image/png"), - ("/Vortex_Black_NoBG.png", "image/png"), - ("/Vortex_White_NoBG.png", "image/png"), - ] { - let resp = client.get(server.url(path)).send().await?; - assert_eq!(resp.status(), 200, "GET {path} should be 200"); - let ct = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert!( - ct.starts_with(ct_prefix), - "GET {path}: content-type {ct:?} should start with {ct_prefix:?}" - ); - let cache_control = resp - .headers() - .get(reqwest::header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert!( - cache_control.contains("no-cache"), - "GET {path}: static assets should revalidate so UI CSS/JS changes are not stale" - ); - let bytes = resp.bytes().await?; - assert!(!bytes.is_empty(), "GET {path}: body must not be empty"); - } - Ok(()) -} - -#[tokio::test] -async fn chart_init_uses_bounded_group_hydration() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let js = client - .get(server.url("/static/chart-init.js")) - .send() - .await? - .text() - .await?; - - assert!( - js.contains("data-group-shard-prefix") && js.contains("fetchGroupShard"), - "landing hydration should fetch versioned group shard artifacts" - ); - assert!( - !js.contains(r#""/api/group/""#), - "landing hydration should not put whole-group payloads on the hot path" - ); - assert!( - js.contains(r#""/api/chart/""#) && js.contains("ensureFullHistory"), - "chart-init should use /api/chart for queued full-history warmup" - ); - assert!( - js.contains("HYDRATION_CONCURRENCY") && js.contains("FULL_HISTORY_CONCURRENCY"), - "landing hydration and full-history warmup should bound per-tab fanout" - ); - assert!( - !js.contains("startBackgroundPrefetch();"), - "chart-init must not fan out full-history chart fetches on page load" - ); - assert!( - js.contains("normalizeChartPayload"), - "chart-init should normalize latest-100 payloads onto a virtual full-history x-axis" - ); - assert!( - js.contains("queueGroupFullHistory"), - "opening a group should queue full-history fetches for that group" - ); - assert!( - !js.contains("function maybeRefetchFullPayload"), - "full-history fetches should not depend on the old covers-all interaction gate" - ); - assert!( - !js.contains("WIDE_DEFAULT_GROUPS"), - "group-open hydration should default every group to the latest-100 window" - ); - Ok(()) -} - -/// Every response — landing HTML, chart JSON, bundled JS — flows through -/// `tower-http`'s `CompressionLayer` so a client advertising -/// `Accept-Encoding: gzip` gets a gzipped (or brotli) body. The -/// reqwest dev-dependency is built without `gzip`/`brotli` features, so the -/// transport hands us the compressed bytes verbatim and we can both inspect -/// the `content-encoding` response header and decompress the body manually -/// to confirm it matches the uncompressed snapshot. -#[tokio::test] -async fn responses_are_compressed_when_client_accepts_gzip() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - - // 1. Landing HTML. - let plain_body = client.get(server.url("/")).send().await?.text().await?; - let resp = client - .get(server.url("/")) - .header(reqwest::header::ACCEPT_ENCODING, "gzip") - .send() - .await?; - assert_eq!(resp.status(), 200); - let encoding = resp - .headers() - .get(reqwest::header::CONTENT_ENCODING) - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert_eq!( - encoding, "gzip", - "GET / with Accept-Encoding: gzip should respond with gzip" - ); - let compressed = resp.bytes().await?; - assert!( - compressed.len() < plain_body.len(), - "compressed body ({} B) should be smaller than plain body ({} B)", - compressed.len(), - plain_body.len(), - ); - let mut decoded = String::new(); - GzDecoder::new(&compressed[..]).read_to_string(&mut decoded)?; - assert_eq!( - decoded, plain_body, - "gzipped landing body should decompress to the uncompressed body" - ); - - // 2. Bundled JS — the heaviest static asset; gzip is the whole point. - let plain_js = client - .get(server.url("/static/chart.umd.js")) - .send() - .await? - .bytes() - .await?; - let js_resp = client - .get(server.url("/static/chart.umd.js")) - .header(reqwest::header::ACCEPT_ENCODING, "gzip") - .send() - .await?; - assert_eq!(js_resp.status(), 200); - let js_encoding = js_resp - .headers() - .get(reqwest::header::CONTENT_ENCODING) - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert_eq!( - js_encoding, "gzip", - "/static/chart.umd.js must compress so the cold load isn't dominated by ~200KB of JS" - ); - let compressed_js = js_resp.bytes().await?; - let mut decoded_js = Vec::new(); - GzDecoder::new(&compressed_js[..]).read_to_end(&mut decoded_js)?; - assert_eq!( - decoded_js, - plain_js.as_ref(), - "decompressed chart.umd.js should match the unencoded body byte-for-byte" - ); - - // 3. Brotli is also offered when the client prefers it. - let br_resp = client - .get(server.url("/")) - .header(reqwest::header::ACCEPT_ENCODING, "br") - .send() - .await?; - assert_eq!(br_resp.status(), 200); - let br_encoding = br_resp - .headers() - .get(reqwest::header::CONTENT_ENCODING) - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert_eq!( - br_encoding, "br", - "GET / with Accept-Encoding: br should respond with brotli" - ); - - Ok(()) -} diff --git a/benchmarks-website/server/tests/web_ui.rs b/benchmarks-website/server/tests/web_ui.rs deleted file mode 100644 index 83edf2c86e6..00000000000 --- a/benchmarks-website/server/tests/web_ui.rs +++ /dev/null @@ -1,506 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Integration tests for the v3 web UI features that span the API and -//! HTML routes: -//! -//! - **Per-group hover descriptions** (Task A): editorial blurbs port from -//! v2's `BENCHMARK_DESCRIPTIONS` + `getBenchmarkDescription`. Asserted on -//! the landing page and on the `/group/{slug}` permalink. -//! - **Partial-coverage commits** (Task B): a chart's x-axis includes -//! commits that have NO row in the fact table for this chart, so -//! missing measurements render as visible gaps rather than silently -//! bridged lines. - -mod common; - -use anyhow::Context as _; -use anyhow::Result; -use serde_json::Value; -use serde_json::json; - -use self::common::Server; -use self::common::TOKEN; -use self::common::pick_chart_slug; -use self::common::pick_group_slug; -use self::common::seed; -use self::common::wait_for_materialized_first_chart_commits; - -// ============================================================================= -// Task A — per-group hover descriptions -// ============================================================================= - -/// The landing page renders a small ⓘ icon next to every group title that -/// has a canonical description, with the description surfaced via the -/// `data-tooltip` attribute (CSS-only hover/focus tooltip). The description -/// also appears on `/api/groups`. -#[tokio::test] -async fn landing_page_renders_group_descriptions() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - - // Random Access — verbatim v2 description. - assert!( - body.contains(r#"data-tooltip="Tests performance of selecting arbitrary row indices from a file on NVMe storage""#), - "Random Access description must appear as a hover tooltip on the landing page" - ); - // Compression — verbatim v2 description (the longer wording, not the - // shorter `getBenchmarkDescription` fallback). - assert!( - body.contains(r#"data-tooltip="Measures encoding and decoding throughput (MB/s) for Vortex files and Parquet files (with zstd page compression)""#), - "Compression description must appear as a hover tooltip on the landing page" - ); - // Compression Size — verbatim v2 description. - assert!( - body.contains(r#"data-tooltip="Compares compressed file sizes and compression ratios across different encoding strategies""#), - "Compression Size description must appear as a hover tooltip on the landing page" - ); - // TPC-H NVMe SF=1 — derived description with scale-bytes annotation. - assert!( - body.contains( - r#"data-tooltip="TPC-H benchmark queries on local NVMe storage at SF=1 (~1GB of data)""# - ), - "TPC-H description with scale-bytes annotation must appear on the landing page" - ); - - // The icon itself is keyboard-focusable + role-annotated for a11y. - assert!( - body.contains(r#" Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let slug = pick_group_slug(&server, |s| s == "Random Access").await?; - let body = client - .get(server.url(&format!("/group/{slug}"))) - .send() - .await? - .text() - .await?; - - assert!( - body.contains(r#"data-tooltip="Tests performance of selecting arbitrary row indices from a file on NVMe storage""#), - "group permalink page must render the same description tooltip as the landing page" - ); - assert!( - body.contains(r#" Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let body = client.get(server.url("/")).send().await?.text().await?; - - // Locate the vector-search section and assert no info-icon inside its - // disclosure summary. - let needle = r#"data-group-name="cohere-large-10m / partitioned""#; - let start = body.find(needle).context("vector-search section present")?; - // The `` tag is the disclosure header; we want the slice - // between this section's start and the end of its ``. - let summary_end = body[start..] - .find("") - .map(|p| start + p) - .context("section contains ")?; - let summary = &body[start..summary_end]; - assert!( - !summary.contains("group-info-icon"), - "vector-search group should not render an info-icon (no canonical description), got: {summary}" - ); - Ok(()) -} - -/// `/api/groups` carries the description on every group entry as a `description` -/// field, so external API consumers can render their own UI without having to -/// hard-code v2's description list. -#[tokio::test] -async fn groups_api_carries_description_field() -> Result<()> { - let server = Server::start().await?; - seed(&server).await?; - - let client = reqwest::Client::new(); - let groups: Value = client - .get(server.url("/api/groups")) - .send() - .await? - .json() - .await?; - let arr = groups["groups"].as_array().context("groups[] array")?; - let by_name = |n: &str| { - arr.iter() - .find(|g| g["name"].as_str() == Some(n)) - .with_context(|| format!("group {n:?} present")) - }; - assert_eq!( - by_name("Random Access")?["description"].as_str(), - Some("Tests performance of selecting arbitrary row indices from a file on NVMe storage"), - ); - assert_eq!( - by_name("TPC-H (NVMe) (SF=1)")?["description"].as_str(), - Some("TPC-H benchmark queries on local NVMe storage at SF=1 (~1GB of data)"), - ); - // Vector-search group has no canonical description; the `description` - // key should be absent (skip_serializing_if). - let vsg = by_name("cohere-large-10m / partitioned")?; - assert!( - vsg.get("description").is_none(), - "vector-search group should not carry a description field, got: {vsg}" - ); - Ok(()) -} - -// ============================================================================= -// Task B — partial-coverage commits -// ============================================================================= - -/// Build an envelope that records a `random_access_time` measurement only -/// for the listed `(format, value_ns)` pairs. The fixture commits' SHAs are -/// deterministic so tests can assert on them. -fn ra_envelope(sha: &str, ts: &str, msg: &str, rows: &[(&str, i64)]) -> Value { - json!({ - "run_meta": { - "benchmark_id": "partial-coverage-fixture", - "schema_version": 1, - "started_at": ts - }, - "commit": { - "sha": sha, - "timestamp": ts, - "message": msg, - "author_name": "Test Author", - "author_email": "author@example.com", - "committer_name": "Test Committer", - "committer_email": "committer@example.com", - "tree_sha": "fedcba9876543210fedcba9876543210fedcba98", - "url": format!("https://github.com/vortex-data/vortex/commit/{sha}") - }, - "records": rows.iter().map(|(format, value_ns)| json!({ - "kind": "random_access_time", - "commit_sha": sha, - "dataset": "taxi", - "format": format, - "value_ns": value_ns, - "all_runtimes_ns": [value_ns] - })).collect::>() - }) -} - -/// Regression test for "charts have invisible gaps where commits should be." -/// -/// Seed three commits A, B, C in chronological order: -/// * A — series X and Y both have data -/// * B — only series Y has data (X crashed; this is the partial-coverage case) -/// * C — series X and Y both have data -/// -/// The chart's `commits[]` must include all three commits (B included), -/// and series X's value at B must be `null`. Before the fix the chart -/// silently dropped B because `SeriesAccumulator::ensure_commit` only -/// registered commits that had at least one row in the fact table. -#[tokio::test] -async fn chart_includes_commits_with_partial_series_coverage() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - let envelopes = [ - ra_envelope( - "aaaa111111111111111111111111111111111111", - "2026-04-23T12:00:00Z", - "A: both series", - &[("vortex-file-compressed", 500), ("parquet", 1_000)], - ), - ra_envelope( - "bbbb222222222222222222222222222222222222", - "2026-04-24T12:00:00Z", - "B: only parquet (vortex crashed)", - &[("parquet", 1_100)], - ), - ra_envelope( - "cccc333333333333333333333333333333333333", - "2026-04-25T12:00:00Z", - "C: both series", - &[("vortex-file-compressed", 600), ("parquet", 1_200)], - ), - ]; - for env in &envelopes { - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(env) - .send() - .await?; - anyhow::ensure!( - resp.status().is_success(), - "seed ingest failed: {}", - resp.status() - ); - } - wait_for_materialized_first_chart_commits(&server, 3).await?; - - let slug = pick_chart_slug(&server, |s| s == "Random Access").await?; - let chart: Value = client - .get(server.url(&format!("/api/chart/{slug}"))) - .send() - .await? - .json() - .await?; - - let commits = chart["commits"].as_array().context("commits[] array")?; - let shas: Vec<&str> = commits.iter().filter_map(|c| c["sha"].as_str()).collect(); - assert_eq!( - shas, - vec![ - "aaaa111111111111111111111111111111111111", - "bbbb222222222222222222222222222222222222", - "cccc333333333333333333333333333333333333", - ], - "all three commits must appear in commits[], including the partial-coverage commit B" - ); - - // Series X (vortex-file-compressed) has data at A and C, NULL at B. - let vortex = chart["series"]["vortex-file-compressed"] - .as_array() - .context("vortex-file-compressed series array")?; - assert_eq!(vortex.len(), 3, "series array aligns with commits[]"); - assert_eq!(vortex[0].as_f64(), Some(500.0)); - assert!( - vortex[1].is_null(), - "vortex-file-compressed must be null at the partial-coverage commit, got {:?}", - vortex[1], - ); - assert_eq!(vortex[2].as_f64(), Some(600.0)); - - // Series Y (parquet) has data at all three commits. - let parquet = chart["series"]["parquet"] - .as_array() - .context("parquet series array")?; - assert_eq!(parquet[0].as_f64(), Some(1_000.0)); - assert_eq!(parquet[1].as_f64(), Some(1_100.0)); - assert_eq!(parquet[2].as_f64(), Some(1_200.0)); - - Ok(()) -} - -/// A commit with NO row in the chart's fact table (every benchmark crashed -/// for that commit) still appears on the chart's x-axis as long as it falls -/// within the chart's window — i.e. ≥ the earliest commit that has data. -/// -/// Seed two commits with random-access data and one commit that only has a -/// `compression_size` row. The compression-size-only commit is in the -/// `commits` dim but has nothing in `random_access_times`, so the random- -/// access chart should still place it on the x-axis with NULL for every -/// series. -#[tokio::test] -async fn chart_includes_commits_with_zero_rows_in_fact_table() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - // Commit A: random-access only. - let env_a = ra_envelope( - "aaaa111111111111111111111111111111111111", - "2026-04-23T12:00:00Z", - "A", - &[("parquet", 1_000)], - ); - // Commit B (chronologically between A and C): a compression_size row, - // nothing in random_access_times. - let env_b = json!({ - "run_meta": { - "benchmark_id": "partial-coverage-fixture", - "schema_version": 1, - "started_at": "2026-04-24T12:00:00Z" - }, - "commit": { - "sha": "bbbb222222222222222222222222222222222222", - "timestamp": "2026-04-24T12:00:00Z", - "message": "B: random-access did not run (only compression_size emitted)", - "author_name": "Test Author", - "author_email": "author@example.com", - "committer_name": "Test Committer", - "committer_email": "committer@example.com", - "tree_sha": "fedcba9876543210fedcba9876543210fedcba98", - "url": "https://github.com/vortex-data/vortex/commit/bbbb222222222222222222222222222222222222" - }, - "records": [ - { - "kind": "compression_size", - "commit_sha": "bbbb222222222222222222222222222222222222", - "dataset": "tpch-lineitem", - "format": "parquet", - "value_bytes": 4_000, - }, - ], - }); - // Commit C: random-access again. - let env_c = ra_envelope( - "cccc333333333333333333333333333333333333", - "2026-04-25T12:00:00Z", - "C", - &[("parquet", 1_200)], - ); - - for env in [&env_a, &env_b, &env_c] { - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(env) - .send() - .await?; - anyhow::ensure!( - resp.status().is_success(), - "seed ingest failed: {}", - resp.status() - ); - } - wait_for_materialized_first_chart_commits(&server, 3).await?; - - let slug = pick_chart_slug(&server, |s| s == "Random Access").await?; - let chart: Value = client - .get(server.url(&format!("/api/chart/{slug}"))) - .send() - .await? - .json() - .await?; - - let shas: Vec<&str> = chart["commits"] - .as_array() - .context("commits[] array")? - .iter() - .filter_map(|c| c["sha"].as_str()) - .collect(); - assert_eq!( - shas, - vec![ - "aaaa111111111111111111111111111111111111", - "bbbb222222222222222222222222222222222222", - "cccc333333333333333333333333333333333333", - ], - "the commit with zero rows in the fact table must still appear in commits[]" - ); - - // The parquet series has data only at A and C. - let parquet = chart["series"]["parquet"] - .as_array() - .context("parquet series array")?; - assert_eq!(parquet.len(), 3); - assert_eq!(parquet[0].as_f64(), Some(1_000.0)); - assert!( - parquet[1].is_null(), - "parquet must be null at the zero-rows commit" - ); - assert_eq!(parquet[2].as_f64(), Some(1_200.0)); - - Ok(()) -} - -/// Commits older than the earliest fact-table row for this chart are NOT -/// included on the x-axis. Without this lower bound a chart's first commit -/// could be from before the benchmark even existed — the spec calls this -/// out explicitly as "Beware: don't accidentally include EVERY commit ever." -#[tokio::test] -async fn chart_excludes_commits_before_first_fact_row() -> Result<()> { - let server = Server::start().await?; - let client = reqwest::Client::new(); - - // Commit A: a `compression_time` row (random-access does not exist for A). - let env_a = json!({ - "run_meta": { - "benchmark_id": "partial-coverage-fixture", - "schema_version": 1, - "started_at": "2026-04-22T12:00:00Z" - }, - "commit": { - "sha": "aaaa111111111111111111111111111111111111", - "timestamp": "2026-04-22T12:00:00Z", - "message": "A: pre-history of the random-access bench", - "author_name": "Test Author", - "author_email": "author@example.com", - "committer_name": "Test Committer", - "committer_email": "committer@example.com", - "tree_sha": "fedcba9876543210fedcba9876543210fedcba98", - "url": "https://github.com/vortex-data/vortex/commit/aaaa111111111111111111111111111111111111" - }, - "records": [ - { - "kind": "compression_time", - "commit_sha": "aaaa111111111111111111111111111111111111", - "dataset": "tpch-lineitem", - "format": "parquet", - "op": "encode", - "value_ns": 9_000, - "all_runtimes_ns": [9_000], - }, - ], - }); - // Commit B: first random-access row appears. - let env_b = ra_envelope( - "bbbb222222222222222222222222222222222222", - "2026-04-23T12:00:00Z", - "B: random-access bench begins", - &[("parquet", 1_000)], - ); - - for env in [&env_a, &env_b] { - let resp = client - .post(server.url("/api/ingest")) - .bearer_auth(TOKEN) - .json(env) - .send() - .await?; - anyhow::ensure!( - resp.status().is_success(), - "seed ingest failed: {}", - resp.status() - ); - } - wait_for_materialized_first_chart_commits(&server, 1).await?; - - let slug = pick_chart_slug(&server, |s| s == "Random Access").await?; - let chart: Value = client - .get(server.url(&format!("/api/chart/{slug}"))) - .send() - .await? - .json() - .await?; - - let shas: Vec<&str> = chart["commits"] - .as_array() - .context("commits[] array")? - .iter() - .filter_map(|c| c["sha"].as_str()) - .collect(); - assert_eq!( - shas, - vec!["bbbb222222222222222222222222222222222222"], - "commit A predates the first random-access row, so it must not be on the x-axis" - ); - Ok(()) -} diff --git a/benchmarks/compress-bench/src/main.rs b/benchmarks/compress-bench/src/main.rs index ce48001abb5..8d9fa73f915 100644 --- a/benchmarks/compress-bench/src/main.rs +++ b/benchmarks/compress-bench/src/main.rs @@ -134,6 +134,14 @@ async fn run_compress( StructListOfInts::new(100, 1000, 50), StructListOfInts::new(1000, 1000, 50), StructListOfInts::new(10000, 1000, 50), + // See https://github.com/vortex-data/vortex/issues/8330 + // Very wide file: project a fixed 10k columns out of 100k, across 10 chunks. + // StructListOfInts::new_with_projection( + // READ_PROJECTION_ROOT_COLUMNS, + // 1000, + // 10, + // Some(READ_PROJECTION_COLUMNS), + // ), ]; let datasets: Vec<&dyn Dataset> = [ diff --git a/benchmarks/compress-bench/src/parquet.rs b/benchmarks/compress-bench/src/parquet.rs index aaf64f81c94..cab4f753948 100644 --- a/benchmarks/compress-bench/src/parquet.rs +++ b/benchmarks/compress-bench/src/parquet.rs @@ -13,12 +13,14 @@ use arrow_schema::Schema; use async_trait::async_trait; use bytes::Bytes; use parquet::arrow::ArrowWriter; +use parquet::arrow::ProjectionMask; use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; use parquet::basic::Compression; use parquet::basic::ZstdLevel; use parquet::file::properties::WriterProperties; use vortex_bench::Format; use vortex_bench::compress::Compressor; +use vortex_bench::compress::read_projection; /// Compressor implementation for Parquet format with ZSTD compression. pub struct ParquetCompressor { @@ -108,7 +110,12 @@ pub fn parquet_compress_write( #[inline(never)] pub fn parquet_decompress_read(buf: Bytes) -> anyhow::Result { - let builder = ParquetRecordBatchReaderBuilder::try_new(buf)?; + let mut builder = ParquetRecordBatchReaderBuilder::try_new(buf)?; + if let Some(cols) = read_projection(builder.schema().fields().len()) { + // Project the given top-level (root) columns. + let mask = ProjectionMask::roots(builder.parquet_schema(), cols.iter().copied()); + builder = builder.with_projection(mask); + } let reader = builder.build()?; let mut nbytes = 0; for batch in reader { diff --git a/benchmarks/compress-bench/src/vortex.rs b/benchmarks/compress-bench/src/vortex.rs index 9d548fd60ec..87416cdf924 100644 --- a/benchmarks/compress-bench/src/vortex.rs +++ b/benchmarks/compress-bench/src/vortex.rs @@ -13,11 +13,15 @@ use bytes::Bytes; use futures::StreamExt; use futures::pin_mut; use vortex::array::IntoArray; +use vortex::dtype::FieldNames; +use vortex::expr::root; +use vortex::expr::select; use vortex::file::OpenOptionsSessionExt; use vortex::file::WriteOptionsSessionExt; use vortex_bench::Format; use vortex_bench::SESSION; use vortex_bench::compress::Compressor; +use vortex_bench::compress::read_projection; use vortex_bench::conversions::parquet_to_vortex_chunks; /// Compressor implementation for Vortex format. @@ -58,7 +62,16 @@ impl Compressor for VortexCompressor { // Now decompress let start = Instant::now(); let data = Bytes::from(buf); - let scan = SESSION.open_options().open_buffer(data)?.scan()?; + let mut scan = SESSION.open_options().open_buffer(data)?.scan()?; + let root_columns = scan + .dtype()? + .as_struct_fields_opt() + .map_or(0, |fields| fields.nfields()); + if let Some(cols) = read_projection(root_columns) { + // Columns are named "0".."num_columns-1"; project the given subset. + let names: FieldNames = cols.iter().map(|i| i.to_string()).collect(); + scan = scan.with_projection(select(names, root())); + } let schema = Arc::new(scan.dtype()?.to_arrow_schema()?); let stream = scan.into_record_batch_stream(schema)?; diff --git a/benchmarks/datafusion-bench/src/lib.rs b/benchmarks/datafusion-bench/src/lib.rs index 469c9be6177..c8353eb1f85 100644 --- a/benchmarks/datafusion-bench/src/lib.rs +++ b/benchmarks/datafusion-bench/src/lib.rs @@ -14,7 +14,7 @@ use datafusion::datasource::provider::DefaultTableFactory; use datafusion::execution::SessionStateBuilder; use datafusion::execution::cache::DefaultListFilesCache; use datafusion::execution::cache::cache_manager::CacheManagerConfig; -use datafusion::execution::cache::cache_unit::DefaultFileStatisticsCache; +use datafusion::execution::cache::file_statistics_cache::DefaultFileStatisticsCache; use datafusion::execution::runtime_env::RuntimeEnvBuilder; use datafusion::prelude::SessionConfig; use datafusion::prelude::SessionContext; @@ -37,7 +37,7 @@ pub fn get_session_context() -> SessionContext { let file_static_cache = Arc::new(DefaultFileStatisticsCache::default()); let list_file_cache = Arc::new(DefaultListFilesCache::default()); let cache_config = CacheManagerConfig::default() - .with_files_statistics_cache(Some(file_static_cache)) + .with_file_statistics_cache(Some(file_static_cache)) .with_list_files_cache(Some(list_file_cache)); rt_builder = rt_builder.with_cache_manager(cache_config); diff --git a/benchmarks/datafusion-bench/src/main.rs b/benchmarks/datafusion-bench/src/main.rs index 52046a03da3..b8f9ac42df6 100644 --- a/benchmarks/datafusion-bench/src/main.rs +++ b/benchmarks/datafusion-bench/src/main.rs @@ -278,7 +278,14 @@ async fn register_benchmark_tables( None => config.infer_schema(&session.state()).await?, }; - let listing_table = Arc::new(ListingTable::try_new(config)?); + let listing_table = Arc::new( + ListingTable::try_new(config)?.with_cache( + session + .runtime_env() + .cache_manager + .get_file_statistic_cache(), + ), + ); session.register_table(table.name, listing_table)?; } diff --git a/benchmarks/duckdb-bench/src/main.rs b/benchmarks/duckdb-bench/src/main.rs index b2a4ff1bd15..8ba4937f566 100644 --- a/benchmarks/duckdb-bench/src/main.rs +++ b/benchmarks/duckdb-bench/src/main.rs @@ -178,6 +178,11 @@ fn main() -> anyhow::Result<()> { args.threads, )?; ctx.register_tables(&*benchmark, format)?; + + // Duckdb doesn't support octet_length for strings but we need this + // in ClickBench. + ctx.execute_query_result("create macro if not exists octet_length(a) as strlen(a)")?; + Ok(ctx) }, |ctx, query_idx, format, query| { diff --git a/benchmarks/lance-bench/src/compress.rs b/benchmarks/lance-bench/src/compress.rs index a7732730ef7..4d8df8f24dd 100644 --- a/benchmarks/lance-bench/src/compress.rs +++ b/benchmarks/lance-bench/src/compress.rs @@ -20,6 +20,7 @@ use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; use tempfile::TempDir; use vortex_bench::Format; use vortex_bench::compress::Compressor; +use vortex_bench::compress::read_projection; use crate::convert::convert_utf8view_batch; use crate::convert::convert_utf8view_schema; @@ -28,7 +29,13 @@ use crate::convert::convert_utf8view_schema; pub async fn lance_decompress_read(path: &str) -> anyhow::Result { // Open the Lance dataset from the filesystem path. let dataset = Dataset::open(path).await?; - let scanner = dataset.scan(); + let mut scanner = dataset.scan(); + + // Apply the fixed wide-table read projection, if any. Columns are named "0".."n-1". + if let Some(cols) = read_projection(dataset.schema().fields.len()) { + let names: Vec = cols.iter().map(|i| i.to_string()).collect(); + scanner.project(&names)?; + } // Convert to a stream of RecordBatches. let mut stream = scanner.try_into_stream().await?; diff --git a/benchmarks/vector-search-bench/Cargo.toml b/benchmarks/vector-search-bench/Cargo.toml deleted file mode 100644 index 02528526080..00000000000 --- a/benchmarks/vector-search-bench/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -[package] -name = "vector-search-bench" -description = "Vector similarity search benchmarks for Vortex on public embedding datasets" -authors.workspace = true -categories.workspace = true -edition.workspace = true -homepage.workspace = true -include.workspace = true -keywords.workspace = true -license.workspace = true -readme.workspace = true -repository.workspace = true -rust-version.workspace = true -version.workspace = true -publish = false - -[dependencies] -anyhow = { workspace = true } -arrow-array = { workspace = true } -arrow-buffer = { workspace = true } -arrow-schema = { workspace = true } -clap = { workspace = true, features = ["derive"] } -futures = { workspace = true } -indicatif = { workspace = true } -parquet = { workspace = true, features = ["async"] } -rand = { workspace = true } -rand_distr = { workspace = true } -serde = { workspace = true, features = ["derive"] } -tabled = { workspace = true, features = ["std"] } -tokio = { workspace = true, features = ["full"] } -tracing = { workspace = true } -vortex = { workspace = true, features = ["files", "tokio", "unstable_encodings"] } -vortex-bench = { workspace = true, features = ["unstable_encodings"] } -vortex-btrblocks = { workspace = true, features = ["unstable_encodings"] } -vortex-tensor = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } - -[lints] -workspace = true diff --git a/benchmarks/vector-search-bench/scripts/plot-turboquant-distortion.py b/benchmarks/vector-search-bench/scripts/plot-turboquant-distortion.py deleted file mode 100644 index 6d6b1f22b4f..00000000000 --- a/benchmarks/vector-search-bench/scripts/plot-turboquant-distortion.py +++ /dev/null @@ -1,596 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "matplotlib", -# ] -# /// - -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors - -"""Sweep bits-vs-distortion for TurboQuant and plot the curves. - -Calls `vector-search-bench distortion` for each (dataset, bits) combination, parses the -table from stdout, and plots reconstruction NMSE and squared cosine-error curves with -mean/median/max shown on a log-scaled y-axis. - -Each `--dataset` value may optionally pin a train layout with a colon, e.g. -`--dataset cohere-small-100k:single`, for datasets that host more than one layout. - -Usage: - uv run benchmarks/vector-search-bench/scripts/plot-turboquant-distortion.py \\ - --dataset sift-small-500k - uv run benchmarks/vector-search-bench/scripts/plot-turboquant-distortion.py \\ - --dataset sift-small-500k --dataset glove-small-100k --samples 8192 - uv run benchmarks/vector-search-bench/scripts/plot-turboquant-distortion.py \\ - --dataset cohere-small-100k:single --bits 1 2 3 4 5 6 7 8 \\ - --output /tmp/distortion.png -""" - -import argparse -import math -import re -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D -from matplotlib.ticker import MaxNLocator, NullLocator - -REPO_ROOT = Path(__file__).resolve().parents[3] -DEFAULT_BINARY = REPO_ROOT / "target" / "release" / "vector-search-bench" - -METRIC_NAMES = [ - "reconstruction NMSE mean", - "reconstruction NMSE median", - "reconstruction NMSE max", - "decoded cosine sqerr mean", - "decoded cosine sqerr median", - "decoded cosine sqerr max", -] - - -@dataclass(frozen=True) -class DatasetTarget: - """One dataset to sweep, with the layout the bench should use for it.""" - - name: str - layout: str | None # `None` means let the bench auto-pick. - - -@dataclass -class Run: - target: DatasetTarget - dim: int - bits: int - values: dict[str, float] - - @property - def dataset(self) -> str: - return self.target.name - - -DIM_RE = re.compile(r"dim=(\d+)") - - -def parse_dataset_arg(spec: str, default_layout: str | None) -> DatasetTarget: - """Split a `name[:layout]` CLI value. `default_layout` fills in when no `:` is given.""" - if ":" in spec: - name, layout = spec.split(":", 1) - return DatasetTarget(name=name, layout=layout or None) - return DatasetTarget(name=spec, layout=default_layout) - - -def parse_dim(stdout: str) -> int: - """Pull `dim=N` out of the `## ...` header line.""" - match = DIM_RE.search(stdout) - if not match: - raise RuntimeError(f"could not find dim=N in header:\n{stdout}") - return int(match.group(1)) - - -def parse_table(stdout: str) -> dict[str, float]: - """Pull `metric -> value` rows out of the tabled stdout.""" - row_re = re.compile(r"│\s*(.+?)\s*│\s*([^│]+?)\s*│") - values: dict[str, float] = {} - for line in stdout.splitlines(): - match = row_re.match(line) - if not match: - continue - metric, value = match.group(1).strip(), match.group(2).strip() - if metric in METRIC_NAMES: - values[metric] = float(value) - missing = [m for m in METRIC_NAMES if m not in values] - if missing: - raise RuntimeError(f"could not parse metrics {missing} from:\n{stdout}") - return values - - -def run_one( - binary: Path, - target: DatasetTarget, - bits: int, - samples: int, - seed: int, - rounds: int, -) -> Run: - cmd = [ - str(binary), - "distortion", - "--dataset", - target.name, - "--bits", - str(bits), - "--samples", - str(samples), - "--seed", - str(seed), - "--rounds", - str(rounds), - ] - if target.layout: - cmd.extend(["--layout", target.layout]) - layout_tag = f" layout={target.layout}" if target.layout else "" - print(f" running {target.name}{layout_tag} @ bits={bits} ...", file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - return Run( - target=target, - dim=parse_dim(result.stdout), - bits=bits, - values=parse_table(result.stdout), - ) - - -# Refined small-b values from `main.tex` line 273-274 ("for b = 1, 2, 3, 4 we have -# D_mse approx 0.36, 0.117, 0.03, 0.009"). Tighter than the general sqrt(3)*pi/2 * 4^(-b) -# upper bound, which is what we fall back to for b >= 5. -_NMSE_UPPER_REFINED = {1: 0.36, 2: 0.117, 3: 0.03, 4: 0.009} - - -def nmse_bound_stage1(bits: int) -> float: - """Paper's Stage-1 unit-norm reconstruction upper bound for TurboQuant_mse. - - From the Stage 1 theorem (`main.tex`, line 272): for a unit-norm vector `x` quantized - to `b` bits per coordinate, `E[||x - x'||^2] <= (sqrt(3)*pi/2) * 4^(-b)`. TurboQuant - internally normalizes each input before quantizing, so the bound applies to per-row - NMSE = `||x - x'||^2 / ||x||^2 = ||unit(x) - unit(x')||^2` directly. For small `b` - (1..=4) the paper gives tighter refined values; we splice those in. - """ - if bits in _NMSE_UPPER_REFINED: - return _NMSE_UPPER_REFINED[bits] - return (math.sqrt(3.0) * math.pi / 2.0) / (4.0**bits) - - -def nmse_lower_bound(bits: int) -> float: - """Paper's Shannon lower bound on Stage-1 unit-norm reconstruction. - - From `main.tex` line 297: `D_mse(Q) >= 1/4^b` for any randomized `b`-bit quantizer. - Independent of dimension; applies to NMSE for the same reason as the upper bound. - """ - return 1.0 / (4.0**bits) - - -def compression_ratio(bits: int, dim: int) -> float: - """Theoretical TurboQuant compression ratio vs f32 storage. - - Per the `vortex_tensor::encodings::turboquant` module docs, each vector is stored - as `padded_dim * bits / 8` bytes of quantized codes plus one f32 stored norm - (4 bytes), where `padded_dim` is the next power of two at least `dim`. The ratio is - nonlinear in `bits` because of POT padding and the per-vector norm overhead. - """ - padded_dim = 1 << (dim - 1).bit_length() if dim > 1 else 1 - per_vector_bytes = padded_dim * bits / 8.0 + 4.0 - original_bytes = dim * 4.0 - return original_bytes / per_vector_bytes - - -def cosine_sqerr_lower_bound(bits: int, dim: int) -> float: - """Paper's Shannon lower bound on Stage-2 squared inner-product distortion. - - From `main.tex` line 298: `D_prod(Q) >= ||y||^2 / d * 1/4^b` for any randomized - `b`-bit quantizer. With unit probes (`||y||^2 = 1`) this is `1 / (d * 4^b)`. - """ - return 1.0 / (dim * (4.0**bits)) - - -DATASET_PALETTE = [ - "#1f77b4", # blue - "#d62728", # red - "#2ca02c", # green - "#9467bd", # purple - "#ff7f0e", # orange - "#17becf", # teal - "#e377c2", # pink - "#8c564b", # brown - "#7f7f7f", # grey - "#bcbd22", # olive -] - -STAT_STYLES = [ - # (metric_suffix, label, linestyle, linewidth, marker) - ("mean", "mean", "-", 2.4, "o"), - ("max", "max", ":", 1.4, None), -] - - -def plot(runs: list[Run], args: argparse.Namespace) -> None: - by_dataset: dict[str, list[Run]] = {} - for r in runs: - by_dataset.setdefault(r.dataset, []).append(r) - for ds_runs in by_dataset.values(): - ds_runs.sort(key=lambda r: r.bits) - - plt.rcParams.update( - { - "font.size": 11, - "axes.titlesize": 13, - "axes.titleweight": "semibold", - "axes.labelsize": 11, - "axes.spines.top": False, - "axes.spines.right": False, - "axes.grid": True, - "grid.alpha": 0.25, - "grid.linewidth": 0.6, - "legend.frameon": False, - } - ) - - # GridSpec with a dedicated bottom strip for the caption so the long text gets a real - # subplot rect: no clipping by `bbox_inches`, no overlap with axis labels, no reliance - # on matplotlib's `wrap=True` heuristic. Plot row gets the lion's share so the bottom - # caption strip doesn't dominate visually; legends are anchored above the axes via - # `bbox_to_anchor` (see `add_legends`), and constrained_layout reserves space for them - # inside the plot row. - fig = plt.figure(figsize=(22, 9.5), constrained_layout=True) - gs = fig.add_gridspec(2, 3, height_ratios=[12, 1]) - axes = [fig.add_subplot(gs[0, i]) for i in range(3)] - caption_ax = fig.add_subplot(gs[1, :]) - caption_ax.axis("off") - fig.suptitle( - f"TurboQuant distortion vs bits per coordinate" - f" (samples={args.samples:,}, seed={args.seed}, rounds={args.rounds})", - fontsize=14, - fontweight="semibold", - ) - - dataset_colors = {ds: DATASET_PALETTE[i % len(DATASET_PALETTE)] for i, ds in enumerate(by_dataset)} - dataset_dims = {ds: ds_runs[0].dim for ds, ds_runs in by_dataset.items()} - - plot_panel( - axes[0], - by_dataset, - dataset_colors, - metric_prefix="reconstruction NMSE", - title=r"Reconstruction NMSE (per vector, $\|x - x^\prime\|^2 / \|x\|^2$)", - ylabel=r"$\|x - x^\prime\|^2 / \|x\|^2$", - ) - bits_axis = sorted({r.bits for r in runs}) - axes[0].plot( - bits_axis, - [nmse_bound_stage1(b) for b in bits_axis], - color="#222222", - linestyle=(0, (4, 2, 1, 2)), - linewidth=1.6, - zorder=0, - ) - axes[0].plot( - bits_axis, - [nmse_lower_bound(b) for b in bits_axis], - color="#222222", - linestyle=(0, (1, 2)), - linewidth=1.4, - zorder=0, - ) - - plot_panel( - axes[1], - by_dataset, - dataset_colors, - metric_prefix="decoded cosine sqerr", - title=r"Squared cosine error $(\cos(y_i, x_i) - \cos(y_i, x_i^\prime))^2$", - ylabel="squared error", - ) - for dataset, ds_runs in by_dataset.items(): - color = dataset_colors[dataset] - d = ds_runs[0].dim - bits = sorted({r.bits for r in ds_runs}) - axes[1].plot( - bits, - [cosine_sqerr_lower_bound(b, d) for b in bits], - color=color, - linestyle=(0, (1, 2)), - linewidth=1.0, - alpha=0.5, - zorder=0, - ) - - plot_compression_panel(axes[2], by_dataset, dataset_colors) - - add_legends(fig, axes, dataset_colors, dataset_dims) - caption_ax.text( - 0.5, - 1.0, - "NMSE upper bound uses the paper's refined small-b values for b<=4 and the " - "smooth sqrt(3)*pi/2 * 4^(-b) general formula for b>=5. Lower bounds are the " - "Shannon information-theoretic floor for any randomized b-bit quantizer. " - "Vortex ships TurboQuant Stage 1 only, so no Stage-2 inner-product upper " - "bound is drawn on the cosine panel. Probe vectors y_i are sampled iid " - "uniform on the unit sphere. Compression ratio is theoretical " - "(padded_dim * bits / 8 + 4 bytes per vector), excludes per-shard centroid " - "tables and file metadata.", - ha="center", - va="top", - fontsize=9, - color="#555555", - wrap=True, - transform=caption_ax.transAxes, - ) - - if args.output: - fig.savefig(args.output, dpi=140, bbox_inches="tight") - print(f"saved {args.output}", file=sys.stderr) - else: - plt.show() - - -def plot_panel( - ax, - by_dataset: dict[str, list[Run]], - dataset_colors: dict[str, str], - metric_prefix: str, - title: str, - ylabel: str, -) -> None: - for dataset, ds_runs in by_dataset.items(): - color = dataset_colors[dataset] - bits = [r.bits for r in ds_runs] - for stat_key, _label, linestyle, linewidth, marker in STAT_STYLES: - metric = f"{metric_prefix} {stat_key}" - ys = [r.values[metric] for r in ds_runs] - ax.plot( - bits, - ys, - color=color, - linestyle=linestyle, - linewidth=linewidth, - marker=marker, - markersize=6, - markerfacecolor=color, - markeredgecolor="white", - markeredgewidth=0.8, - alpha=0.95 if marker else 0.75, - ) - ax.set_yscale("log") - ax.set_xlabel("bits per coordinate") - ax.set_ylabel(ylabel) - ax.set_title(title) - ax.xaxis.set_major_locator(MaxNLocator(integer=True)) - ax.grid(True, which="major", linewidth=0.7, alpha=0.45) - ax.grid(True, which="minor", linewidth=0.4, alpha=0.22) - ax.minorticks_on() - # Only the integer bit-widths should get an x-axis line; suppress the in-between - # minor ticks that `minorticks_on()` adds (the y-axis minors stay - they're useful - # on the log scale). - ax.xaxis.set_minor_locator(NullLocator()) - - -def plot_compression_panel( - ax, - by_dataset: dict[str, list[Run]], - dataset_colors: dict[str, str], -) -> None: - bits_axis = sorted({r.bits for runs in by_dataset.values() for r in runs}) - for dataset, ds_runs in by_dataset.items(): - color = dataset_colors[dataset] - d = ds_runs[0].dim - padded = 1 << (d - 1).bit_length() if d > 1 else 1 - suffix = f" (padded {padded})" if padded != d else " (no padding)" - ax.plot( - bits_axis, - [compression_ratio(b, d) for b in bits_axis], - color=color, - linestyle="-", - linewidth=2.4, - marker="o", - markersize=6, - markerfacecolor=color, - markeredgecolor="white", - markeredgewidth=0.8, - label=f"{dataset}{suffix}", - ) - ax.set_xlabel("bits per coordinate") - ax.set_ylabel(r"ratio vs f32 (= $4d \,/\, (\mathrm{padded}\!\cdot\! b/8 + 4)$)") - ax.set_title("Compression ratio (theoretical)") - ax.xaxis.set_major_locator(MaxNLocator(integer=True)) - ax.grid(True, which="major", linewidth=0.7, alpha=0.45) - ax.grid(True, which="minor", linewidth=0.4, alpha=0.22) - ax.minorticks_on() - ax.xaxis.set_minor_locator(NullLocator()) - ax.legend( - title="dataset", - loc="lower center", - bbox_to_anchor=(0.5, 1.02), - ncol=2, - fontsize=9, - title_fontsize=10, - ) - - -def add_legends( - fig, - axes, - dataset_colors: dict[str, str], - dataset_dims: dict[str, int], -) -> None: - dataset_handles = [ - Line2D( - [], - [], - color=color, - linewidth=2.4, - marker="o", - markersize=6, - markerfacecolor=color, - markeredgecolor="white", - markeredgewidth=0.8, - label=f"{dataset} (d = {dataset_dims[dataset]})", - ) - for dataset, color in dataset_colors.items() - ] - stat_handles = [ - Line2D( - [], - [], - color="#333333", - linestyle=linestyle, - linewidth=linewidth, - marker=marker, - markersize=6 if marker else 0, - markerfacecolor="#333333", - markeredgecolor="white", - markeredgewidth=0.8, - label=label, - ) - for _, label, linestyle, linewidth, marker in STAT_STYLES - ] - nmse_upper_handle = Line2D( - [], - [], - color="#222222", - linestyle=(0, (4, 2, 1, 2)), - linewidth=1.6, - label=( - r"upper bound: " - r"$D_{\mathrm{mse}} \leq \frac{\sqrt{3}\,\pi}{2}\, 4^{-b}$ (refined for $b\!\leq\!4$)" - ), - ) - nmse_lower_handle = Line2D( - [], - [], - color="#222222", - linestyle=(0, (1, 2)), - linewidth=1.4, - label=r"lower bound: $D_{\mathrm{mse}} \geq 4^{-b}$", - ) - cosine_lower_handle = Line2D( - [], - [], - color="#444444", - linestyle=(0, (1, 2)), - linewidth=1.0, - alpha=0.5, - label=r"lower bound: $D_{\mathrm{prod}} \geq \frac{1}{d}\, 4^{-b}$", - ) - - axes[0].legend( - handles=dataset_handles + [nmse_upper_handle, nmse_lower_handle], - title="dataset / bound", - loc="lower center", - bbox_to_anchor=(0.5, 1.02), - ncol=2, - fontsize=10, - title_fontsize=10, - ) - axes[1].legend( - handles=stat_handles + [cosine_lower_handle], - title="statistic / bound", - loc="lower center", - bbox_to_anchor=(0.5, 1.02), - ncol=3, - fontsize=10, - title_fontsize=10, - ) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - "--dataset", - action="append", - required=True, - help=( - "Dataset to sweep (repeat to compare multiple). Optionally suffix " - "`:layout` to pin a specific train layout for that dataset, e.g. " - "`--dataset cohere-small-100k:single`. If omitted, the bench picks " - "the dataset's only layout, or errors if there are several." - ), - ) - parser.add_argument( - "--layout", - default=None, - help=("Default train layout applied to any `--dataset` entry that doesn't pin its own with `:layout`."), - ) - parser.add_argument("--samples", type=int, default=65536) - parser.add_argument( - "--bits", - type=int, - nargs="+", - default=[1, 2, 3, 4, 5, 6, 7, 8], - help="Bit widths to sweep (default: 1..=8).", - ) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--rounds", type=int, default=3) - parser.add_argument( - "--binary", - type=Path, - default=DEFAULT_BINARY, - help=f"Path to vector-search-bench (default: {DEFAULT_BINARY}).", - ) - parser.add_argument( - "--output", - type=Path, - default=None, - help="If set, save the chart to this path instead of opening a window.", - ) - args = parser.parse_args() - - print("building vector-search-bench (release) ...", file=sys.stderr) - subprocess.run( - ["cargo", "build", "-p", "vector-search-bench", "--release"], - cwd=REPO_ROOT, - check=True, - ) - - if not args.binary.exists(): - sys.exit(f"binary not found at {args.binary} after build") - - targets = [parse_dataset_arg(spec, args.layout) for spec in args.dataset] - - runs: list[Run] = [] - for target in targets: - layout_tag = f" (layout={target.layout})" if target.layout else "" - print( - f"sweeping {target.name}{layout_tag} over bits {args.bits} ...", - file=sys.stderr, - ) - for bits in args.bits: - runs.append( - run_one( - args.binary, - target, - bits, - args.samples, - args.seed, - args.rounds, - ) - ) - - print_summary(runs) - plot(runs, args) - - -def print_summary(runs: list[Run]) -> None: - print() - print("Summary (one row per (dataset, bits)):") - header = ["dataset", "dim", "bits"] + METRIC_NAMES - widths = [max(len(h), 14) for h in header] - print(" " + " ".join(h.ljust(w) for h, w in zip(header, widths))) - for r in runs: - cells = [r.dataset, str(r.dim), str(r.bits)] + [f"{r.values[m]:.3e}" for m in METRIC_NAMES] - print(" " + " ".join(c.ljust(w) for c, w in zip(cells, widths))) - - -if __name__ == "__main__": - main() diff --git a/benchmarks/vector-search-bench/src/compression.rs b/benchmarks/vector-search-bench/src/compression.rs deleted file mode 100644 index 9b40cb15544..00000000000 --- a/benchmarks/vector-search-bench/src/compression.rs +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Vector compression flavors exercised by the benchmark. -//! -//! Each [`VectorFlavor`] variant maps to a [`vortex::file::WriteStrategyBuilder`] configuration -//! applied to the same input data. -//! -//! The benchmark writes one `.vortex` file per flavor per data file, then scans them all with the -//! same query so the comparison is apples-to-apples with the Parquet files. - -use clap::ValueEnum; -use vortex::array::ArrayId; -use vortex::array::scalar_fn::ScalarFnVTable; -use vortex::file::ALLOWED_ENCODINGS; -use vortex::file::VortexWriteOptions; -use vortex::file::WriteOptionsSessionExt; -use vortex::file::WriteStrategyBuilder; -use vortex::session::VortexSession; -use vortex::utils::aliases::hash_set::HashSet; -use vortex_bench::Format; -use vortex_btrblocks::BtrBlocksCompressorBuilder; -use vortex_tensor::encodings::l2_denorm::L2DenormScheme; -use vortex_tensor::scalar_fns::l2_denorm::L2Denorm; -use vortex_tensor::scalar_fns::sorf_transform::SorfTransform; - -/// Every [`VectorFlavor`] variant in CLI-help order. -pub const ALL_VECTOR_FLAVORS: &[VectorFlavor] = - &[VectorFlavor::Uncompressed, VectorFlavor::TurboQuant]; - -/// One write-side compression configuration we measure. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -pub enum VectorFlavor { - /// `BtrBlocksCompressorBuilder::empty()` - #[clap(name = "vortex-uncompressed")] - Uncompressed, - /// `BtrBlocksCompressorBuilder::default().with_turboquant()`. - #[clap(name = "vortex-turboquant")] - TurboQuant, - // TODO(connor): We will want to add `Default` here which is just the default compressor. -} - -impl VectorFlavor { - /// Stable kebab-cased label used in CLI args and metric names. - pub fn label(&self) -> &'static str { - match self { - VectorFlavor::Uncompressed => "vortex-uncompressed", - VectorFlavor::TurboQuant => "vortex-turboquant", - } - } - - /// The `target.format` value emitted on measurements for this flavor. Both flavors produce - /// `.vortex` files, so the compression label carries the flavor split. - pub fn as_format(&self) -> Format { - match self { - VectorFlavor::Uncompressed => Format::OnDiskVortex, - VectorFlavor::TurboQuant => Format::OnDiskVortex, - } - } - - /// Subdirectory name under the per-dataset cache root used to store this flavor's `.vortex` - /// files. - pub fn dir_name(&self) -> &'static str { - match self { - VectorFlavor::Uncompressed => "vortex-uncompressed", - VectorFlavor::TurboQuant => "vortex-turboquant", - } - } - - /// Build the [`vortex::file::WriteStrategyBuilder`]-backed write options for this flavor. - /// - /// TurboQuant produces `L2Denorm(SorfTransform(...))` which the default file - /// `ALLOWED_ENCODINGS` set rejects on normalization — we extend the allow-list with the two - /// scalar-fn array IDs the scheme actually emits. - pub fn create_write_options(&self, session: &VortexSession) -> VortexWriteOptions { - let strategy = match self { - VectorFlavor::Uncompressed => { - // Even though this is uncompressed, we still want to denormalize the data first so - // that the results are fair. - let compressor = BtrBlocksCompressorBuilder::empty() - .with_new_scheme(&L2DenormScheme) - .build(); - - let mut allowed: HashSet = ALLOWED_ENCODINGS.clone(); - allowed.insert(L2Denorm.id()); - - WriteStrategyBuilder::default() - .with_compressor(compressor) - .with_allow_encodings(allowed) - .build() - } - VectorFlavor::TurboQuant => { - let compressor = BtrBlocksCompressorBuilder::default() - .with_turboquant() - .build(); - - let mut allowed: HashSet = ALLOWED_ENCODINGS.clone(); - allowed.insert(L2Denorm.id()); - allowed.insert(SorfTransform.id()); - - WriteStrategyBuilder::default() - .with_compressor(compressor) - .with_allow_encodings(allowed) - .build() - } - }; - - session.write_options().with_strategy(strategy) - } -} diff --git a/benchmarks/vector-search-bench/src/display.rs b/benchmarks/vector-search-bench/src/display.rs deleted file mode 100644 index a74eec46b01..00000000000 --- a/benchmarks/vector-search-bench/src/display.rs +++ /dev/null @@ -1,133 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Local table renderer for the vector-search benchmark. -//! -//! Groups columns by **flavor** (`vortex-uncompressed`, `vortex-turboquant`) rather than by -//! [`vortex_bench::Format`], because the two Vortex flavors share a single -//! `Format::OnDiskVortex`/`Format::VortexLossy` pair and the generic -//! [`vortex_bench::display::render_table`] groups by Format. Local renderer keeps the -//! column-per-flavor invariant intact without introducing a new global Format value. -//! -//! Output rows: -//! -//! ```text -//! Metric | vortex-uncompressed | vortex-turboquant -//! ------------------ + ------------------- + ----------------- -//! scan wall (mean) | 485 ms | 212 ms -//! scan wall (median) | 490 ms | 215 ms -//! matches | 42 | 39 -//! rows scanned | 10,000,000 | 10,000,000 -//! bytes scanned | 30.5 GB | 7.62 GB -//! rows / sec | 5.2e6 | 1.2e7 -//! ``` - -use std::io::Write; - -use anyhow::Result; -use tabled::settings::Style; - -use crate::compression::VectorFlavor; -use crate::prepare::CompressedVortexDataset; -use crate::scan::ScanTiming; - -/// Final column-per-flavor row set for one dataset. -pub struct DatasetReport<'a> { - pub dataset_name: &'a str, - pub vortex_results: &'a [(VectorFlavor, &'a CompressedVortexDataset, &'a ScanTiming)], -} - -/// Render the full report into the given writer as a tabled table. -pub fn render(report: &DatasetReport<'_>, writer: &mut dyn Write) -> Result<()> { - let mut headers: Vec = vec!["metric".to_owned()]; - for &(flavor, ..) in report.vortex_results { - headers.push(flavor.label().to_owned()); - } - - let rows: Vec> = vec![ - make_row("scan wall (mean)", report, |_, _, scan| { - format_duration(scan.mean) - }), - make_row("scan wall (median)", report, |_, _, scan| { - format_duration(scan.median) - }), - make_row("matches", report, |_, _, scan| scan.matches.to_string()), - make_row("rows scanned", report, |_, _, scan| { - scan.rows_scanned.to_string() - }), - make_row("bytes scanned", report, |_, _, scan| { - format_bytes(scan.bytes_scanned) - }), - make_row("rows / sec", report, |_, _, scan| { - format_throughput_rows(scan.rows_scanned, scan.mean) - }), - ]; - - writeln!(writer, "## {}", report.dataset_name)?; - let mut builder = tabled::builder::Builder::new(); - builder.push_record(headers); - for row in rows { - builder.push_record(row); - } - let mut table = builder.build(); - table.with(Style::modern()); - writeln!(writer, "{table}")?; - Ok(()) -} - -fn make_row(metric: &str, report: &DatasetReport<'_>, vortex_cell: F) -> Vec -where - F: Fn(VectorFlavor, &CompressedVortexDataset, &ScanTiming) -> String, -{ - let mut row = vec![metric.to_owned()]; - for &(flavor, prep, scan) in report.vortex_results { - row.push(vortex_cell(flavor, prep, scan)); - } - row -} - -fn format_duration(d: std::time::Duration) -> String { - let secs = d.as_secs_f64(); - if secs >= 1.0 { - format!("{secs:.2} s") - } else if secs >= 1e-3 { - format!("{:.1} ms", secs * 1e3) - } else { - format!("{:.1} µs", secs * 1e6) - } -} - -fn format_bytes(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"]; - let mut value = bytes as f64; - let mut unit = UNITS[0]; - for next in &UNITS[1..] { - if value < 1024.0 { - break; - } - value /= 1024.0; - unit = next; - } - if unit == "B" { - format!("{bytes} B") - } else { - format!("{value:.2} {unit}") - } -} - -fn format_throughput_rows(rows: u64, wall: std::time::Duration) -> String { - let secs = wall.as_secs_f64(); - if secs <= 0.0 { - return "—".to_owned(); - } - let rps = rows as f64 / secs; - if rps >= 1e9 { - format!("{:.2}G", rps / 1e9) - } else if rps >= 1e6 { - format!("{:.2}M", rps / 1e6) - } else if rps >= 1e3 { - format!("{:.2}K", rps / 1e3) - } else { - format!("{rps:.0}") - } -} diff --git a/benchmarks/vector-search-bench/src/distortion.rs b/benchmarks/vector-search-bench/src/distortion.rs deleted file mode 100644 index 7a05410522e..00000000000 --- a/benchmarks/vector-search-bench/src/distortion.rs +++ /dev/null @@ -1,370 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! TurboQuant distortion measurement on real vector datasets. -//! -//! Reports the normalized mean square error (`||x - x'||^2 / ||x||^2`) and the squared -//! cosine-similarity error (`(cos(y_i, x_i) - cos(y_i, x'_i))^2`) against a set of independently -//! sampled unit-norm probe vectors `y_i`, after a full encode and decode roundtrip through the -//! [`vortex_tensor::encodings::turboquant`] scheme. -//! -//! NMSE rather than raw SSE because TurboQuant internally normalizes each input to unit -//! norm before quantizing (storing `||x||` separately), so the paper's Stage-1 bound -//! `E[||unit(x) - unit(x')||^2] <= (sqrt(3) * pi / 2) * 4^(-b)` applies to NMSE directly; -//! raw `||x - x'||^2` sits at `||x||^2` times that bound and isn't comparable across rows. - -use std::io::Write; - -use anyhow::Context; -use anyhow::Result; -use anyhow::bail; -use rand::SeedableRng; -use rand::rngs::StdRng; -use rand_distr::Distribution; -use rand_distr::Normal; -use tabled::settings::Style; -use vortex::array::ArrayRef; -use vortex::array::ExecutionCtx; -use vortex::array::IntoArray; -use vortex::array::VortexSessionExecute; -use vortex::array::arrays::ExtensionArray; -use vortex::array::arrays::FixedSizeListArray; -use vortex::array::arrays::PrimitiveArray; -use vortex::array::arrays::Struct; -use vortex::array::arrays::StructArray; -use vortex::array::arrays::extension::ExtensionArrayExt; -use vortex::array::arrays::fixed_size_list::FixedSizeListArrayExt; -use vortex::array::arrays::struct_::StructArrayExt; -use vortex::error::VortexExpect; -use vortex_bench::conversions::parquet_to_vortex_chunks; -use vortex_bench::vector_dataset; -use vortex_bench::vector_dataset::TrainLayout; -use vortex_bench::vector_dataset::VectorDataset; -use vortex_tensor::encodings::turboquant::TurboQuantConfig; -use vortex_tensor::encodings::turboquant::turboquant_encode; - -use crate::SESSION; -use crate::ingest::transform_chunk; - -/// Inputs to a distortion run. -#[derive(Debug, Clone)] -pub struct DistortionConfig { - /// Dataset to load vectors from. - pub dataset: VectorDataset, - /// Train-split layout (used to locate the local parquet shards). - pub layout: TrainLayout, - /// Bits per quantized coordinate. - pub bits: u8, - /// Seed for the SORF rotation. - pub seed: u64, - /// Number of sign-diagonal plus Walsh-Hadamard rounds in the SORF transform. - pub rounds: u8, - /// Number of base vectors to sample from the first train shard. - pub samples: usize, -} - -/// Mean, median, and max of a sample of distortion measurements. -#[derive(Debug, Clone)] -pub struct DistortionStats { - /// Arithmetic mean. - pub mean: f32, - /// Median (mid element after a partial sort). - pub median: f32, - /// Maximum. - pub max: f32, -} - -/// Per-dataset distortion report ready to render as markdown. -#[derive(Debug, Clone)] -pub struct DistortionReport { - /// Dataset the vectors came from. - pub dataset: VectorDataset, - /// Train-split layout used to locate the shard. - pub layout: TrainLayout, - /// Vector dimensionality. - pub dim: u32, - /// Bits per quantized coordinate. - pub bits: u8, - /// Seed for the SORF rotation. - pub seed: u64, - /// Number of SORF rounds. - pub rounds: u8, - /// Number of base vectors sampled. - pub samples: usize, - /// Normalized squared reconstruction error per row, `||x - x'||^2 / ||x||^2`. - pub reconstruction: DistortionStats, - /// Squared cosine-similarity error per row against a random unit-norm probe `y_i`, - /// `(cos(y_i, x_i) - cos(y_i, x'_i))^2`. - pub decoded_cosine: DistortionStats, -} - -/// Compute reconstruction error and cosine-similarity error for a TurboQuant roundtrip. -pub async fn run_distortion(config: &DistortionConfig) -> Result { - let dataset = config.dataset; - let layout = config.layout; - - let paths = vector_dataset::download(dataset, layout) - .await - .with_context(|| format!("download {}", dataset.name()))?; - let train_path = paths - .train_files - .first() - .with_context(|| format!("dataset {} has no train shards", dataset.name()))? - .clone(); - - let mut ctx = SESSION.create_execution_ctx(); - - let chunked = parquet_to_vortex_chunks(train_path).await?; - let struct_array: StructArray = chunked.into_array().execute(&mut ctx)?; - let transformed = transform_chunk(struct_array.into_array(), &mut ctx)?; - let emb_full = transformed - .as_opt::() - .with_context(|| { - format!( - "transform_chunk did not return a Struct, got {}", - transformed.dtype() - ) - })? - .unmasked_field_by_name("emb") - .context("transformed chunk missing `emb` field")? - .clone(); - - let n = config.samples.min(emb_full.len()); - if n == 0 { - bail!( - "distortion: need at least one sampled vector, got 0 (dataset {})", - dataset.name(), - ); - } - let emb = emb_full.slice(0..n)?; - - let original = extract_flat_f32(&emb, &mut ctx)?; - let dim = pairs_per_row(&original, n)?; - - let tq_config = TurboQuantConfig { - bit_width: config.bits, - seed: config.seed, - num_rounds: config.rounds, - }; - let encoded = turboquant_encode(emb, &tq_config, &mut ctx)?; - let decoded_ext: ExtensionArray = encoded.execute(&mut ctx)?; - let decoded = decoded_ext.into_array(); - let decoded_flat = extract_flat_f32(&decoded, &mut ctx)?; - - let reconstruction = stats(&reconstruction_nmse(&original, &decoded_flat, dim, n)); - - // Sample independent unit-norm probe vectors `y_i` (one per row). The TurboQuant Stage-2 - // bound `E[( - )^2] <= sqrt(3) * pi^2 / d * 4^(-b)` holds for any fixed `y`, - // so drawing `y` from the unit sphere is a reasonable empirical sweep. - let probes = random_unit_vectors(n, dim, config.seed)?; - let decoded_cosine = stats(&squared_cosine_errors( - &original, - &decoded_flat, - &probes, - dim, - n, - )); - - Ok(DistortionReport { - dataset, - layout, - dim: u32::try_from(dim).context("dim must fit in u32")?, - bits: config.bits, - seed: config.seed, - rounds: config.rounds, - samples: n, - reconstruction, - decoded_cosine, - }) -} - -/// Extract a flat `f32` slice from a `Vector` extension array. -fn extract_flat_f32(array: &ArrayRef, ctx: &mut ExecutionCtx) -> Result> { - let ext: ExtensionArray = array.clone().execute(ctx)?; - let fsl: FixedSizeListArray = ext.storage_array().clone().execute(ctx)?; - let elements: PrimitiveArray = fsl.elements().clone().execute(ctx)?; - Ok(elements.as_slice::().to_vec()) -} - -fn pairs_per_row(flat: &[f32], num_rows: usize) -> Result { - if num_rows == 0 { - bail!("distortion: cannot derive dim from zero rows"); - } - if !flat.len().is_multiple_of(num_rows) { - bail!( - "distortion: flat element count {} not divisible by row count {num_rows}", - flat.len(), - ); - } - Ok(flat.len() / num_rows) -} - -/// Normalized squared reconstruction error per row, `||x - x'||^2 / ||x||^2`. Zero-norm -/// rows are dropped from the sample because NMSE is undefined when `||x|| = 0`, and our -/// vector datasets are not expected to contain zero vectors. -fn reconstruction_nmse( - original: &[f32], - reconstructed: &[f32], - dim: usize, - num_rows: usize, -) -> Vec { - (0..num_rows) - .filter_map(|row| { - let start = row * dim; - let end = start + dim; - let orig = &original[start..end]; - let recon = &reconstructed[start..end]; - let norm_sq: f32 = orig.iter().map(|&v| v * v).sum(); - if norm_sq == 0.0 { - return None; - } - let err_sq: f32 = orig - .iter() - .zip(recon.iter()) - .map(|(&a, &b)| (a - b) * (a - b)) - .sum(); - Some(err_sq / norm_sq) - }) - .collect() -} - -/// Sample `num_rows` independent `dim`-D vectors with standard-normal entries and normalize each -/// row to unit L2 norm. Used as probe vectors `y_i` for the squared cosine-similarity error. -fn random_unit_vectors(num_rows: usize, dim: usize, seed: u64) -> Result> { - let mut rng = StdRng::seed_from_u64(seed); - let normal = Normal::new(0.0_f32, 1.0).context("constructing Normal(0, 1)")?; - let mut buf = vec![0.0_f32; num_rows * dim]; - for row in 0..num_rows { - let start = row * dim; - let end = start + dim; - for v in &mut buf[start..end] { - *v = normal.sample(&mut rng); - } - let norm = buf[start..end].iter().map(|&v| v * v).sum::().sqrt(); - if norm > 0.0 { - for v in &mut buf[start..end] { - *v /= norm; - } - } - } - Ok(buf) -} - -/// Cosine similarity of two equal-length vectors, returning `0.0` if either has zero norm. -/// A zero-norm decoded vector represents genuine quantizer failure, so the caller still -/// gets a defined per-row error that reflects the lost direction. -fn cosine(a: &[f32], b: &[f32]) -> f32 { - let dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum(); - let norm_a: f32 = a.iter().map(|&v| v * v).sum::().sqrt(); - let norm_b: f32 = b.iter().map(|&v| v * v).sum::().sqrt(); - let denom = norm_a * norm_b; - if denom == 0.0 { 0.0 } else { dot / denom } -} - -/// Per-row squared cosine-similarity error against probe `y_i`, -/// `(cos(y_i, x_i) - cos(y_i, x'_i))^2`. Rows whose original `x_i` has zero norm are -/// dropped, matching [`reconstruction_nmse`]. -fn squared_cosine_errors( - original: &[f32], - reconstructed: &[f32], - probes: &[f32], - dim: usize, - num_rows: usize, -) -> Vec { - (0..num_rows) - .filter_map(|row| { - let start = row * dim; - let end = start + dim; - let xi = &original[start..end]; - let xi_dec = &reconstructed[start..end]; - let yi = &probes[start..end]; - if xi.iter().map(|&v| v * v).sum::() == 0.0 { - return None; - } - let diff = cosine(yi, xi) - cosine(yi, xi_dec); - Some(diff * diff) - }) - .collect() -} - -fn stats(samples: &[f32]) -> DistortionStats { - if samples.is_empty() { - return DistortionStats { - mean: f32::NAN, - median: f32::NAN, - max: f32::NAN, - }; - } - - let sum: f64 = samples.iter().map(|&v| f64::from(v)).sum(); - #[expect( - clippy::cast_possible_truncation, - reason = "casting an f64 mean back to f32 is intentional and matches the input precision" - )] - let mean = (sum / samples.len() as f64) as f32; - - let mut sorted = samples.to_vec(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let mid = sorted.len() / 2; - let median = if sorted.len() % 2 == 1 { - sorted[mid] - } else { - 0.5 * (sorted[mid - 1] + sorted[mid]) - }; - - let max = samples - .iter() - .copied() - .reduce(f32::max) - .vortex_expect("samples is non-empty per the early return above"); - - DistortionStats { mean, median, max } -} - -impl DistortionReport { - /// Render the report as a markdown header line followed by a tabled table. - pub fn render(&self, writer: &mut dyn Write) -> Result<()> { - writeln!( - writer, - "## {} | dim={} | layout={} | bits={} | samples={} | seed={} | rounds={}", - self.dataset.name(), - self.dim, - self.layout.label(), - self.bits, - self.samples, - self.seed, - self.rounds, - )?; - - let rows: &[(&str, f32)] = &[ - ("reconstruction NMSE mean", self.reconstruction.mean), - ("reconstruction NMSE median", self.reconstruction.median), - ("reconstruction NMSE max", self.reconstruction.max), - ("decoded cosine sqerr mean", self.decoded_cosine.mean), - ("decoded cosine sqerr median", self.decoded_cosine.median), - ("decoded cosine sqerr max", self.decoded_cosine.max), - ]; - - let mut builder = tabled::builder::Builder::new(); - builder.push_record(["metric", "value"]); - for &(metric, value) in rows { - builder.push_record([metric.to_owned(), format_metric(value)]); - } - let mut table = builder.build(); - table.with(Style::modern()); - writeln!(writer, "{table}")?; - Ok(()) - } -} - -fn format_metric(value: f32) -> String { - if value.is_nan() { - "nan".to_owned() - } else if value == 0.0 { - "0".to_owned() - } else if value.abs() < 1e-3 || value.abs() >= 1e4 { - format!("{value:.3e}") - } else { - format!("{value:.6}") - } -} diff --git a/benchmarks/vector-search-bench/src/expression.rs b/benchmarks/vector-search-bench/src/expression.rs deleted file mode 100644 index b1a5b1ef1c3..00000000000 --- a/benchmarks/vector-search-bench/src/expression.rs +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Cosine-similarity filter [`Expression`]s used by the file-scan path. -//! -//! We can easily build a cosine similarity filter by hand: -//! -//! ```text -//! gt( -//! cosine_similarity(col("emb"), lit(query_scalar)), -//! lit(threshold), -//! ) -//! ``` -//! -//! The query is wrapped as `Scalar::extension::(Scalar::fixed_size_list(F32, ...))` so -//! [`CosineSimilarity`] can treat it as a single-row `Vector` value during evaluation. -//! -//! At scan time the literal expands into a `ConstantArray` whose row count matches the chunk batch -//! size. - -use anyhow::Result; -use vortex::array::expr::Expression; -use vortex::array::expr::col; -use vortex::array::expr::gt; -use vortex::array::expr::lit; -use vortex::array::extension::EmptyMetadata; -use vortex::array::scalar::Scalar; -use vortex::array::scalar_fn::EmptyOptions; -use vortex::array::scalar_fn::ScalarFnVTableExt; -use vortex::dtype::DType; -use vortex::dtype::Nullability; -use vortex::dtype::PType; -use vortex_tensor::scalar_fns::cosine_similarity::CosineSimilarity; -use vortex_tensor::vector::Vector; - -/// Build the filter `cosine_similarity(emb, query) > threshold`. -pub fn similarity_filter(query: &[f32], threshold: f32) -> Result { - // Empty queries short-circuit to a literal `false`, so scans return no rows instead of trying - // to evaluate cosine similarity on a zero-dimensional vector. - if query.is_empty() { - return Ok(lit(false)); - } - - let query_lit = lit(query_scalar(query)?); - let cosine = CosineSimilarity.new_expr(EmptyOptions, [col("emb"), query_lit]); - Ok(gt(cosine, lit(threshold))) -} - -/// Wrap a query vector as `Scalar::extension::(Scalar::fixed_size_list(F32, ...))`. -pub fn query_scalar(query: &[f32]) -> Result { - let children: Vec = query - .iter() - .map(|&v| Scalar::primitive(v, Nullability::NonNullable)) - .collect(); - - let element_dtype = DType::Primitive(PType::F32, Nullability::NonNullable); - let fsl = Scalar::fixed_size_list(element_dtype, children, Nullability::NonNullable); - - Ok(Scalar::extension::(EmptyMetadata, fsl)) -} - -/// Project just the `emb` column. Used by the throughput-only scan path. -pub fn emb_projection() -> Expression { - col("emb") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn query_scalar_accepts_empty_query() { - let scalar = query_scalar(&[]).unwrap(); - match scalar.dtype() { - DType::Extension(_) => {} - other => panic!("expected Extension, got {other}"), - } - } - - #[test] - fn query_scalar_builds_extension_dtype() { - let scalar = query_scalar(&[1.0, 0.0, 0.0]).unwrap(); - match scalar.dtype() { - DType::Extension(_) => {} - other => panic!("expected Extension, got {other}"), - } - } - - #[test] - fn similarity_filter_uses_gt_operator() { - let expr = similarity_filter(&[1.0, 0.0, 0.0], 0.5).unwrap(); - // Quick sanity check: the printed form contains the operator and the threshold so - // future refactors that change the structure get caught here. - let printed = format!("{expr:?}"); - assert!(printed.contains("Gt") || printed.contains(">"), "{printed}"); - } -} diff --git a/benchmarks/vector-search-bench/src/ingest.rs b/benchmarks/vector-search-bench/src/ingest.rs deleted file mode 100644 index bab176b0482..00000000000 --- a/benchmarks/vector-search-bench/src/ingest.rs +++ /dev/null @@ -1,221 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Per-chunk ingest transform. -//! -//! Bridges the parquet record-batch stream and the Vortex file writer: -//! -//! 1. Project the `emb` column out of each struct chunk. -//! 2. Rewrap the `emb` column as `Extension>` via -//! [`vortex_bench::vector_dataset::list_to_vector_ext`]. -//! 3. Detect the FSL element ptype at runtime and cast `f64` -> `f32` when needed. Detection is -//! from the arrow schema rather than a catalog declaration so upstream parquets whose actual -//! precision disagrees with the catalog still ingest correctly. After this point all -//! downstream code (compression, scan, recall) is f32-only. -//! 4. Optionally project the `scalar_labels` column through unchanged so future filtered-search -//! benchmarks have it without re-ingest. -//! 5. Repackage as `Struct { id: i64, emb: Vector, scalar_labels: ??? }`. - -use anyhow::Context; -use anyhow::Result; -use anyhow::bail; -use anyhow::ensure; -use vortex::array::ArrayRef; -use vortex::array::ExecutionCtx; -use vortex::array::IntoArray; -use vortex::array::arrays::ExtensionArray; -use vortex::array::arrays::FixedSizeListArray; -use vortex::array::arrays::PrimitiveArray; -use vortex::array::arrays::Struct; -use vortex::array::arrays::StructArray; -use vortex::array::arrays::extension::ExtensionArrayExt; -use vortex::array::arrays::fixed_size_list::FixedSizeListArrayExt; -use vortex::array::arrays::struct_::StructArrayExt; -use vortex::array::extension::EmptyMetadata; -use vortex::array::validity::Validity; -use vortex::buffer::Buffer; -use vortex::dtype::DType; -use vortex::dtype::PType; -use vortex::dtype::extension::ExtDType; -use vortex_bench::vector_dataset::list_to_vector_ext; -use vortex_tensor::vector::AnyVector; -use vortex_tensor::vector::Vector; - -/// Apply the transform to a single struct chunk and return the rebuilt chunk. -/// -/// `chunk` must be a non-chunked `Struct { id: i64, emb: List }`, where all of the list -/// elements are -/// -/// The returned array is always a `Struct { id: i64, emb: Vector }`. -pub fn transform_chunk(chunk: ArrayRef, ctx: &mut ExecutionCtx) -> Result { - let struct_view = chunk - .as_opt::() - .with_context(|| format!("ingest: expected struct chunk, got dtype {}", chunk.dtype()))?; - - let id = struct_view - .unmasked_field_by_name("id") - .context("ingest: chunk missing `id` column")? - .clone(); - let emb = struct_view - .unmasked_field_by_name("emb") - .context("ingest: chunk missing `emb` column")? - .clone(); - - let emb_ext: ExtensionArray = list_to_vector_ext(emb)?.execute(ctx)?; - - // Detect the actual FSL element ptype from the extension storage dtype. The dataset catalog - // cannot be trusted here: at least one upstream parquet (`sift-medium-5m`) ships f64 - // embeddings despite the catalog advertising f32. - let element_ptype = { - let storage_dtype = emb_ext.storage_array().dtype(); - match storage_dtype { - DType::FixedSizeList(elem, ..) => match elem.as_ref() { - DType::Primitive(ptype, _) => *ptype, - other => bail!("ingest: expected primitive FSL element dtype, got {other}"), - }, - other => bail!("ingest: expected FSL storage dtype, got {other}"), - } - }; - - let f32_vector_array = match element_ptype { - PType::F32 => emb_ext.into_array(), - PType::F64 => convert_f64_to_f32_vectors(&emb_ext, ctx)?, - other => bail!("ingest: unsupported emb element ptype {other}, expected f32 or f64"), - }; - - let fields = [("id", id), ("emb", f32_vector_array)]; - Ok(StructArray::from_fields(&fields)?.into_array()) -} - -/// Convert a `Vector` extension array down to `Vector`. -/// -/// This conversion is lossy, but we are generally ok with this because most vector search -/// operations do not demand a high amount of precision. -fn convert_f64_to_f32_vectors(ext: &ExtensionArray, ctx: &mut ExecutionCtx) -> Result { - ensure!(ext.ext_dtype().is::()); - - let fsl: FixedSizeListArray = ext.storage_array().clone().execute(ctx)?; - let validity = fsl.validity()?; - let elements: PrimitiveArray = fsl.elements().clone().execute(ctx)?; - ensure!(elements.ptype() == PType::F64); - - let dim = match fsl.dtype() { - DType::FixedSizeList(_, dim, _) => *dim, - other => bail!("cast_vector_ext_to_f32: expected FSL dtype, got {other}"), - }; - - let f64_slice = elements.as_slice::(); - - #[expect( - clippy::cast_possible_truncation, - reason = "this is intentionally lossy" - )] - let f32_buf: Buffer = f64_slice - .iter() - .copied() - .map(|double| double as f32) - .collect(); - - let f32_elements = PrimitiveArray::new::(f32_buf, Validity::NonNullable).into_array(); - let new_fsl = FixedSizeListArray::try_new(f32_elements, dim, validity, fsl.len())?; - let ext_dtype = ExtDType::::try_new(EmptyMetadata, new_fsl.dtype().clone())?.erased(); - - Ok(ExtensionArray::new(ext_dtype, new_fsl.into_array()).into_array()) -} - -#[cfg(test)] -mod tests { - use vortex::VortexSessionDefault; - use vortex::array::VortexSessionExecute; - use vortex::array::arrays::List; - use vortex::buffer::BufferMut; - use vortex::dtype::Nullability; - use vortex::session::VortexSession; - - use super::*; - - fn list_chunk_f64(rows: &[&[f64]]) -> ArrayRef { - let mut elements = BufferMut::::with_capacity(rows.iter().map(|r| r.len()).sum()); - let mut offsets = BufferMut::::with_capacity(rows.len() + 1); - offsets.push(0); - for row in rows { - for &v in row.iter() { - elements.push(v); - } - offsets.push(i32::try_from(elements.len()).unwrap()); - } - let elements_array = - PrimitiveArray::new::(elements.freeze(), Validity::NonNullable).into_array(); - let offsets_array = - PrimitiveArray::new::(offsets.freeze(), Validity::NonNullable).into_array(); - vortex::array::Array::::new(elements_array, offsets_array, Validity::NonNullable) - .into_array() - } - - fn id_array(ids: &[i64]) -> ArrayRef { - PrimitiveArray::new::( - BufferMut::from_iter(ids.iter().copied()).freeze(), - Validity::NonNullable, - ) - .into_array() - } - - #[test] - fn f64_chunk_is_cast_to_f32() -> Result<()> { - let session = VortexSession::default(); - let mut ctx = session.create_execution_ctx(); - - let emb = list_chunk_f64(&[&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]]); - let chunk = - StructArray::from_fields(&[("id", id_array(&[0, 1])), ("emb", emb)])?.into_array(); - let out = transform_chunk(chunk, &mut ctx)?; - let out_struct = out - .as_opt::() - .context("transform_chunk should return a Struct array")?; - let out_emb = out_struct - .unmasked_field_by_name("emb") - .context("transform_chunk output should contain an emb field")? - .clone(); - let DType::Extension(ext) = out_emb.dtype() else { - panic!("expected extension dtype, got {}", out_emb.dtype()); - }; - match ext.storage_dtype() { - DType::FixedSizeList(elem, 3, Nullability::NonNullable) => { - assert_eq!( - **elem, - DType::Primitive(PType::F32, Nullability::NonNullable) - ); - } - other => panic!("unexpected storage dtype {other}"), - } - Ok(()) - } - - #[test] - fn f32_chunk_passes_through() -> Result<()> { - let session = VortexSession::default(); - let mut ctx = session.create_execution_ctx(); - - let mut elements = BufferMut::::with_capacity(6); - for v in [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0] { - elements.push(v); - } - let mut offsets = BufferMut::::with_capacity(3); - offsets.push(0); - offsets.push(3); - offsets.push(6); - let emb = vortex::array::Array::::new( - PrimitiveArray::new::(elements.freeze(), Validity::NonNullable).into_array(), - PrimitiveArray::new::(offsets.freeze(), Validity::NonNullable).into_array(), - Validity::NonNullable, - ) - .into_array(); - let chunk = - StructArray::from_fields(&[("id", id_array(&[0, 1])), ("emb", emb)])?.into_array(); - - let out = transform_chunk(chunk, &mut ctx)?; - let out_struct = out.as_opt::().expect("returns Struct"); - assert_eq!(out_struct.len(), 2); - Ok(()) - } -} diff --git a/benchmarks/vector-search-bench/src/lib.rs b/benchmarks/vector-search-bench/src/lib.rs deleted file mode 100644 index 76b24390d09..00000000000 --- a/benchmarks/vector-search-bench/src/lib.rs +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! `vector-search-bench` vector similarity-search benchmark over several datasets. - -pub mod compression; -pub mod display; -pub mod distortion; -pub mod expression; -pub mod ingest; -pub mod prepare; -pub mod query; -pub mod scan; - -use std::sync::LazyLock; - -use anyhow::Result; -use vortex::VortexSessionDefault; -use vortex::io::session::RuntimeSessionExt; -use vortex::session::VortexSession; -use vortex_bench::vector_dataset::TrainLayout; -use vortex_bench::vector_dataset::VectorDataset; - -pub static SESSION: LazyLock = LazyLock::new(|| { - // SAFETY: called from inside the LazyLock initializer, before any other access to - // `SESSION`. The first thread to dereference SESSION runs this once. - unsafe { std::env::set_var(vortex_tensor::SCALAR_FN_ARRAY_TENSOR_PLUGIN_ENV, "1") }; - - let session = VortexSession::default().with_tokio(); - vortex_tensor::initialize(&session); - session -}); - -/// Resolve a dataset's [`TrainLayout`]. -/// -/// Every benchmark has different sets of possible dataset layouts available. The user **must** -/// provide one if there are multiple layouts. But if a dataset only has 1 layout, we can choose -/// that for them as the default. -pub fn resolve_layout( - dataset: VectorDataset, - requested: Option, -) -> Result { - let layouts = dataset.layouts(); - - match requested { - Some(layout) => { - dataset.validate_layout(layout)?; - Ok(layout) - } - None => { - if layouts.len() == 1 { - Ok(layouts[0].layout()) - } else { - let allowed = layouts - .iter() - .map(|s| s.layout().label()) - .collect::>() - .join(", "); - anyhow::bail!( - "dataset {} hosts multiple layouts ([{}]): pass --layout to pick one", - dataset.name(), - allowed, - ); - } - } - } -} diff --git a/benchmarks/vector-search-bench/src/main.rs b/benchmarks/vector-search-bench/src/main.rs deleted file mode 100644 index 307c050d221..00000000000 --- a/benchmarks/vector-search-bench/src/main.rs +++ /dev/null @@ -1,284 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! `vector-search-bench` benchmarks for cosine-similarity scan and TurboQuant distortion. -//! -//! ```sh -//! cargo run -p vector-search-bench --release -- search \ -//! --dataset cohere-large-10m \ -//! --layout partitioned \ -//! --flavors vortex-uncompressed,vortex-turboquant \ -//! --iterations 3 \ -//! --threshold 0.8 -//! -//! cargo run -p vector-search-bench --release -- distortion \ -//! --dataset sift-small-500k \ -//! --bits 4 \ -//! --samples 4096 -//! ``` - -use std::path::PathBuf; - -use anyhow::Context; -use anyhow::Result; -use clap::Parser; -use clap::Subcommand; -use vector_search_bench::compression::ALL_VECTOR_FLAVORS; -use vector_search_bench::compression::VectorFlavor; -use vector_search_bench::display::DatasetReport; -use vector_search_bench::display::render; -use vector_search_bench::distortion::DistortionConfig; -use vector_search_bench::distortion::run_distortion; -use vector_search_bench::prepare::CompressedVortexDataset; -use vector_search_bench::prepare::prepare_all; -use vector_search_bench::query::get_random_query_vector; -use vector_search_bench::resolve_layout; -use vector_search_bench::scan::ScanConfig; -use vector_search_bench::scan::ScanTiming; -use vector_search_bench::scan::run_search_scan; -use vortex_bench::setup_logging_and_tracing; -use vortex_bench::v3; -use vortex_bench::vector_dataset; -use vortex_bench::vector_dataset::TrainLayout; -use vortex_bench::vector_dataset::VectorDataset; - -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// On-disk cosine-similarity scan latency benchmark. - Search(SearchArgs), - /// TurboQuant distortion measurement: reconstruction error and cosine error. - Distortion(DistortionArgs), -} - -#[derive(Parser, Debug)] -struct SearchArgs { - /// Dataset to benchmark. Single dataset per CLI invocation by design — large datasets - /// are intentionally babysat one at a time. - #[arg(long, value_enum)] - dataset: VectorDataset, - - /// Train-split layout. Required when the dataset publishes more than one layout. - /// Defaults to the catalog's first hosted layout when omitted. - #[arg(long, value_enum)] - layout: Option, - - /// Comma-separated list of flavors to run. Each Vortex flavor produces one `.vortex` file per - /// train shard. - #[arg( - long, - value_delimiter = ',', - value_enum, - default_values_t = ALL_VECTOR_FLAVORS.to_vec(), - )] - flavors: Vec, - - /// Number of timed scan iterations per flavor. Mean and median are reported. - #[arg(long, default_value_t = 5)] - iterations: usize, - - /// Cosine threshold passed to the filter expression. - #[arg(long, default_value_t = 0.85)] - threshold: f32, - - /// Seed for the test-parquet query sampler. - #[arg(long, default_value_t = 42)] - query_seed: u64, - - /// Optional path to write the rendered table to instead of stdout. - #[arg(long)] - output_path: Option, - - /// Additionally write v3 JSONL records to this path. See - /// `benchmarks-website/planning/02-contracts.md`. - #[arg(long)] - gh_json_v3: Option, - - /// Emit verbose tracing. - #[arg(short, long)] - verbose: bool, - - /// Enable perfetto tracing output. - #[arg(long)] - tracing: bool, -} - -#[derive(Parser, Debug)] -struct DistortionArgs { - /// Dataset to load vectors from. One dataset per invocation. - #[arg(long, value_enum)] - dataset: VectorDataset, - - /// Train-split layout. Required when the dataset publishes more than one layout. - #[arg(long, value_enum)] - layout: Option, - - /// Bits per quantized coordinate. - #[arg(long, default_value_t = 4)] - bits: u8, - - /// Seed for the SORF rotation. - #[arg(long, default_value_t = 42)] - seed: u64, - - /// Number of sign-diagonal plus Walsh-Hadamard rounds in the SORF transform. - #[arg(long, default_value_t = 3)] - rounds: u8, - - /// Number of base vectors to sample from the first train shard (first N rows). - #[arg(long, default_value_t = 65536)] - samples: usize, - - /// Optional path to write the rendered table to instead of stdout. - #[arg(long)] - output_path: Option, - - /// Emit verbose tracing. - #[arg(short, long)] - verbose: bool, - - /// Enable perfetto tracing output. - #[arg(long)] - tracing: bool, -} - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - match cli.command { - Command::Search(args) => run_search(args).await, - Command::Distortion(args) => run_distortion_cmd(args).await, - } -} - -async fn run_search(args: SearchArgs) -> Result<()> { - setup_logging_and_tracing(args.verbose, args.tracing)?; - - let dataset = args.dataset; - let layout = resolve_layout(dataset, args.layout)?; - tracing::info!( - "running {} on layout {} ({} dims, {} rows)", - dataset.name(), - layout, - dataset.dim(), - dataset.num_rows() - ); - - if args.flavors.is_empty() { - anyhow::bail!("no flavors selected, please pass at least one to --flavors"); - } - - let datasets_paths = vector_dataset::download(dataset, layout) - .await - .with_context(|| format!("download {}", dataset.name()))?; - - let prepared = prepare_all(dataset, layout, &datasets_paths, &args.flavors).await?; - - let query_vector = get_random_query_vector( - &datasets_paths.test, - dataset.dim(), - dataset.element_ptype(), - args.query_seed, - ) - .await?; - tracing::info!( - "sampled query id {} (dim={})", - query_vector.id, - query_vector.query.len() - ); - - let scan_config = ScanConfig { - iterations: args.iterations, - threshold: args.threshold, - }; - - let mut scan_timings: Vec = Vec::with_capacity(prepared.len()); - for prep in &prepared { - let timing = run_search_scan(prep, &query_vector.query, &scan_config).await?; - scan_timings.push(timing); - } - - let pairs: Vec<(VectorFlavor, &CompressedVortexDataset, &ScanTiming)> = prepared - .iter() - .zip(scan_timings.iter()) - .map(|(prep, scan)| (prep.flavor, prep, scan)) - .collect(); - let report = DatasetReport { - dataset_name: dataset.name(), - vortex_results: &pairs, - }; - - if let Some(path) = args.gh_json_v3.as_ref() { - let records: Vec = scan_timings - .iter() - .map(|scan| { - let all_runs_ns: Vec = scan - .all_runs - .iter() - .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)) - .collect(); - let median_ns = u64::try_from(scan.median.as_nanos()).unwrap_or(u64::MAX); - v3::vector_search_record( - v3::VectorSearchDims { - dataset: dataset.name(), - layout: layout.label(), - flavor: scan.flavor.label(), - threshold: f64::from(args.threshold), - }, - median_ns, - all_runs_ns, - scan.matches, - scan.rows_scanned, - scan.bytes_scanned, - ) - }) - .collect(); - v3::write_jsonl_to_path(path, &records)?; - } - - if let Some(path) = args.output_path { - let mut file = - std::fs::File::create(&path).with_context(|| format!("create {}", path.display()))?; - render(&report, &mut file)?; - } else { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - render(&report, &mut handle)?; - } - - Ok(()) -} - -async fn run_distortion_cmd(args: DistortionArgs) -> Result<()> { - setup_logging_and_tracing(args.verbose, args.tracing)?; - - let layout = resolve_layout(args.dataset, args.layout)?; - let config = DistortionConfig { - dataset: args.dataset, - layout, - bits: args.bits, - seed: args.seed, - rounds: args.rounds, - samples: args.samples, - }; - - let report = run_distortion(&config).await?; - - if let Some(path) = args.output_path { - let mut file = - std::fs::File::create(&path).with_context(|| format!("create {}", path.display()))?; - report.render(&mut file)?; - } else { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - report.render(&mut handle)?; - } - - Ok(()) -} diff --git a/benchmarks/vector-search-bench/src/prepare.rs b/benchmarks/vector-search-bench/src/prepare.rs deleted file mode 100644 index 8cf9d9860ed..00000000000 --- a/benchmarks/vector-search-bench/src/prepare.rs +++ /dev/null @@ -1,227 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Per-flavor on-disk ingest. -//! -//! For each `(dataset, layout, flavor)` triple, [`prepare_flavor`] streams every parquet shard -//! and writes one `.vortex` file per shard. The pipeline is idempotent (existing `.vortex` files -//! are skipped) and reports end-to-end wall-clock time, summed input parquet bytes, and total -//! output bytes. - -use std::path::Path; -use std::path::PathBuf; - -use anyhow::Context; -use anyhow::Result; -use futures::StreamExt; -use parquet::arrow::ParquetRecordBatchStreamBuilder; -use tokio::fs::File; -use tokio::io::AsyncWriteExt; -use tracing::info; -use tracing::warn; -use vortex::array::ArrayRef; -use vortex::array::ExecutionCtx; -use vortex::array::VortexSessionExecute; -use vortex::array::stream::ArrayStreamAdapter; -use vortex::array::stream::ArrayStreamExt; -use vortex::error::VortexResult; -use vortex::error::vortex_err; -use vortex_bench::conversions::parquet_to_vortex_stream; -use vortex_bench::data_dir; -use vortex_bench::utils::file::idempotent_async; -use vortex_bench::vector_dataset::DatasetPaths; -use vortex_bench::vector_dataset::TrainLayout; -use vortex_bench::vector_dataset::VectorDataset; - -use crate::SESSION; -use crate::compression::VectorFlavor; -use crate::ingest::transform_chunk; - -/// The paths of the vortex files that result from preparing one `(dataset, layout, flavor)` triple. -#[derive(Debug, Clone)] -pub struct CompressedVortexDataset { - pub dataset: VectorDataset, - pub layout: TrainLayout, - pub flavor: VectorFlavor, - pub vortex_files: Vec, -} - -/// Drive [`prepare_flavor`] across a list of flavors, returning a [`CompressedVortexDataset`] per -/// flavor in input order. -pub async fn prepare_all( - dataset: VectorDataset, - layout: TrainLayout, - paths_for_dataset: &DatasetPaths, - flavors: &[VectorFlavor], -) -> Result> { - let mut results = Vec::with_capacity(flavors.len()); - - for &flavor in flavors { - let r = prepare_flavor(dataset, layout, paths_for_dataset, flavor).await?; - results.push(r); - } - - Ok(results) -} - -/// Prepare one flavor of one dataset by writing one `.vortex` file per train shard. -/// -/// This function is sequential (for now). -pub async fn prepare_flavor( - dataset: VectorDataset, - layout: TrainLayout, - paths_for_dataset: &DatasetPaths, - flavor: VectorFlavor, -) -> Result { - let mut vortex_files = Vec::with_capacity(paths_for_dataset.train_files.len()); - - for parquet_path in &paths_for_dataset.train_files { - let parquet_path = parquet_path.clone(); - let vortex_path = parquet_to_vortex_path(&parquet_path, dataset, layout, flavor)?; - - let already_cached = vortex_path.exists(); - if already_cached { - warn!( - "skipping cached vortex shard {} ({} flavor)", - vortex_path.display(), - flavor.label() - ); - } else { - info!( - "ingesting {} -> {} ({} flavor)", - parquet_path.display(), - vortex_path.display(), - flavor.label(), - ); - } - - let written_path = idempotent_async(vortex_path.as_path(), |tmp| async move { - write_shard_streaming(&parquet_path, &tmp, flavor).await - }) - .await?; - - vortex_files.push(written_path); - } - - Ok(CompressedVortexDataset { - dataset, - layout, - flavor, - vortex_files, - }) -} - -/// Stream one parquet shard through the chunk transform into a Vortex file. -/// -/// The output dtype is derived once from the first transformed chunk so the [`ArrayStreamAdapter`] -/// can declare it ahead of time. -async fn write_shard_streaming( - parquet_path: &Path, - vortex_path: &Path, - flavor: VectorFlavor, -) -> Result<()> { - let file = File::open(parquet_path).await?; - let builder = ParquetRecordBatchStreamBuilder::new(file).await?; - let mut array_stream = parquet_to_vortex_stream(builder.build()?); - - let mut ctx = SESSION.create_execution_ctx(); - - // We need to get the first chunk so that we know what the dtype of the file is. - let first = match array_stream.next().await { - Some(chunk) => transform_chunk_with_error(chunk, &mut ctx, parquet_path, 1)?, - None => { - return Err(vortex_err!( - "ingest: parquet shard {} produced no chunks", - parquet_path.display(), - ) - .into()); - } - }; - let dtype = first.dtype().clone(); - let shard_path = parquet_path.to_path_buf(); - - let transformed = - futures::stream::iter(std::iter::once(Ok(first))).chain(array_stream.enumerate().map( - move |(chunk_offset, chunk_or_err)| { - let mut local_ctx = SESSION.create_execution_ctx(); - transform_chunk_with_error( - chunk_or_err, - &mut local_ctx, - &shard_path, - chunk_offset + 2, - ) - }, - )); - - let stream = ArrayStreamExt::boxed(ArrayStreamAdapter::new(dtype, transformed)); - - let mut output = tokio::fs::OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open(vortex_path) - .await?; - - flavor - .create_write_options(&SESSION) - .write(&mut output, stream) - .await?; - output.flush().await?; - - Ok(()) -} - -fn transform_chunk_with_error( - chunk_or_err: VortexResult, - ctx: &mut ExecutionCtx, - parquet_path: &Path, - chunk_idx: usize, -) -> VortexResult { - let chunk = chunk_or_err.map_err(|err| { - vortex_err!( - "ingest: failed to read chunk {} from {}: {err:#}", - chunk_idx, - parquet_path.display(), - ) - })?; - - transform_chunk(chunk, ctx).map_err(|err| { - vortex_err!( - "ingest: failed to transform chunk {} from {}: {err:#}", - chunk_idx, - parquet_path.display(), - ) - }) -} - -/// Translate a parquet shard path to its `.vortex` companion under the flavor directory. -/// -/// Just swaps the file extension and rebases the file name into the per-[`VectorFlavor`] flavor -/// directory. The shard stem is preserved so a directory listing pairs `00-of-10.parquet` with -/// `00-of-10.vortex`. -pub fn parquet_to_vortex_path( - parquet: &Path, - dataset: VectorDataset, - layout: TrainLayout, - flavor: VectorFlavor, -) -> Result { - let stem = parquet - .file_stem() - .with_context(|| format!("parquet path {} has no file stem", parquet.display()))? - .to_owned(); - - // TODO(connor): Is there a better way to do this? - let mut name = stem; - name.push(".vortex"); - - Ok(flavor_dir(dataset, layout, flavor).join(name)) -} - -/// `vortex-bench/data/vector-search////`. -fn flavor_dir(ds: VectorDataset, layout: TrainLayout, flavor: VectorFlavor) -> PathBuf { - data_dir() - .join("vector-search") - .join(ds.name()) - .join(layout.label()) - .join(flavor.dir_name()) -} diff --git a/benchmarks/vector-search-bench/src/query.rs b/benchmarks/vector-search-bench/src/query.rs deleted file mode 100644 index cbd9d3bf38e..00000000000 --- a/benchmarks/vector-search-bench/src/query.rs +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Sample one query vector from `test.parquet`. -//! -//! The vector datasets ship a `test.parquet` alongside the train split: these are the query vectors -//! meant to be issued against the index. -//! -//! The benchmark picks a single random row (seeded for reproducibility) and uses it as the query -//! for the scan. - -use std::path::Path; - -use anyhow::Context; -use anyhow::Result; -use anyhow::bail; -use anyhow::ensure; -use rand::RngExt; -use rand::SeedableRng; -use rand::rngs::StdRng; -use vortex::array::IntoArray; -use vortex::array::VortexSessionExecute; -use vortex::array::arrays::StructArray; -use vortex::array::arrays::struct_::StructArrayExt; -use vortex::dtype::PType; -use vortex::error::VortexExpect; -use vortex::error::vortex_err; -use vortex_bench::conversions::parquet_to_vortex_chunks; - -use crate::SESSION; - -/// One query vector sampled from `test.parquet`. -#[derive(Debug, Clone)] -pub struct QuerySample { - /// The ID of the vector. - pub id: i64, - /// f32 query values, length `dim`. - pub query: Vec, -} - -/// Sample one query row from `test.parquet`. -/// -/// The cast to f32 happens here when the source is f64 (matching the prepare-side cast), so that -/// all downstream code is uniformly f32. -pub async fn get_random_query_vector( - test_parquet: &Path, - expected_dim: u32, - src_ptype: PType, - seed: u64, -) -> Result { - let mut ctx = SESSION.create_execution_ctx(); - - let chunked = parquet_to_vortex_chunks(test_parquet.to_path_buf()) - .await - .with_context(|| format!("read test parquet {}", test_parquet.display()))?; - // The `test.parquet` files are generally small enough that this is not a big deal. - let struct_array: StructArray = chunked.into_array().execute(&mut ctx)?; - - let id = struct_array - .unmasked_field_by_name("id") - .context("test parquet missing `id` column")? - .clone(); - let emb = struct_array - .unmasked_field_by_name("emb") - .context("test parquet missing `emb` column")? - .clone(); - - let mut rng = StdRng::seed_from_u64(seed); - let query_row_idx = rng.random_range(0..id.len()); - - let id_scalar = id.execute_scalar(query_row_idx, &mut ctx)?; - let emb_scalar = emb.execute_scalar(query_row_idx, &mut ctx)?; - - ensure!(emb_scalar.as_list().len() == expected_dim as usize); - - let id = id_scalar - .as_primitive() - .as_::() - .ok_or_else(|| vortex_err!("vector ID was not a i64"))?; - - let query_vector = match src_ptype { - PType::F32 => emb_scalar - .as_list() - .elements() - .vortex_expect("somehow had a null test vector") - .iter() - .map(|element| { - element - .as_primitive() - .as_::() - .vortex_expect("value was not a f32") - }) - .collect(), - PType::F64 => - { - #[expect( - clippy::cast_possible_truncation, - reason = "this is intentionally lossy" - )] - emb_scalar - .as_list() - .elements() - .vortex_expect("somehow had a null test vector") - .iter() - .map(|element| { - element - .as_primitive() - .as_::() - .vortex_expect("value was not a f64") as f32 - }) - .collect() - } - ptype => bail!("source ptype {ptype} was somehow not f32 or f64"), - }; - - Ok(QuerySample { - query: query_vector, - id, - }) -} diff --git a/benchmarks/vector-search-bench/src/scan.rs b/benchmarks/vector-search-bench/src/scan.rs deleted file mode 100644 index 81a054f74b3..00000000000 --- a/benchmarks/vector-search-bench/src/scan.rs +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Per-iteration scan driver. -//! -//! Each iteration re-opens every `.vortex` shard fresh (so the segment cache is re-primed -//! per run), pushes the cosine-similarity filter through the scan, and drains the resulting -//! [`vortex::array::stream::ArrayStream`]. The wall-clock around the entire per-iteration -//! pass is the headline number; we track the mean and median across iterations. - -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; - -use anyhow::Context; -use anyhow::Result; -use futures::TryStreamExt; -use vortex::array::ArrayRef; -use vortex::file::OpenOptionsSessionExt; - -use crate::SESSION; -use crate::compression::VectorFlavor; -use crate::expression::similarity_filter; -use crate::prepare::CompressedVortexDataset; - -/// Inputs to a scan run. -#[derive(Debug, Clone)] -pub struct ScanConfig { - /// Number of timed iterations (best-of-N). - pub iterations: usize, - /// Cosine threshold passed to the filter expression. - pub threshold: f32, -} - -/// Aggregate timing + counters for one `(flavor)` scan. -#[derive(Debug, Clone)] -pub struct ScanTiming { - /// Which compression flavor's `.vortex` files were scanned. - pub flavor: VectorFlavor, - /// Arithmetic mean of the per-iteration wall times. - pub mean: Duration, - /// Median of the per-iteration wall times. - pub median: Duration, - /// Per-iteration wall times in run order. - pub all_runs: Vec, - /// Number of rows that survived the filter (constant across iterations because the - /// filter is deterministic). - pub matches: u64, - /// Total rows scanned (sum of file row counts) as a sanity check that the iteration - /// actually walked the files. - pub rows_scanned: u64, - /// Total on-disk size of the scanned `.vortex` files, in bytes. - pub bytes_scanned: u64, -} - -/// Scan every shard in a [`CompressedVortexDataset`] under the given config. -pub async fn run_search_scan( - dataset: &CompressedVortexDataset, - query: &[f32], - config: &ScanConfig, -) -> Result { - anyhow::ensure!( - config.iterations > 0, - "scan iterations must be >= 1, got {}", - config.iterations - ); - - let bytes_scanned = total_file_size(&dataset.vortex_files)?; - - let mut all_runs = Vec::with_capacity(config.iterations); - let mut matches = 0u64; - let mut rows_scanned = 0u64; - - for iter_idx in 0..config.iterations { - let (wall, iter_matches, iter_rows) = - run_one_iteration(&dataset.vortex_files, query, config.threshold).await?; - tracing::debug!( - "{} iter {} -> {:?} ({} matches, {} rows)", - dataset.flavor.label(), - iter_idx, - wall, - iter_matches, - iter_rows, - ); - // Matches and row counts are deterministic across iterations; reset rather than - // accumulate so the reported value matches a single pass. - matches = iter_matches; - rows_scanned = iter_rows; - all_runs.push(wall); - } - - Ok(ScanTiming { - flavor: dataset.flavor, - mean: mean(&all_runs), - median: median(&all_runs), - all_runs, - matches, - rows_scanned, - bytes_scanned, - }) -} - -/// Sum the on-disk sizes of the given files. -fn total_file_size(paths: &[PathBuf]) -> Result { - let mut total = 0u64; - for path in paths { - let meta = - std::fs::metadata(path).with_context(|| format!("stat {} for size", path.display()))?; - total = total.saturating_add(meta.len()); - } - Ok(total) -} - -async fn run_one_iteration( - vortex_files: &[PathBuf], - query: &[f32], - threshold: f32, -) -> Result<(Duration, u64, u64)> { - let mut matches = 0u64; - let mut rows_scanned = 0u64; - - let started = Instant::now(); - for path in vortex_files { - let (m, r) = scan_one_file(path, query, threshold).await?; - matches = matches.saturating_add(m); - rows_scanned = rows_scanned.saturating_add(r); - } - - Ok((started.elapsed(), matches, rows_scanned)) -} - -async fn scan_one_file(path: &Path, query: &[f32], threshold: f32) -> Result<(u64, u64)> { - let file = SESSION - .open_options() - .open_path(path) - .await - .with_context(|| format!("open {}", path.display()))?; - - let total_rows = file.row_count(); - let filter = similarity_filter(query, threshold)?; - let chunks: Vec = file - .scan()? - .with_filter(filter) - .into_array_stream()? - .try_collect() - .await?; - - let matches: u64 = chunks.iter().map(|c| c.len() as u64).sum(); - Ok((matches, total_rows)) -} - -/// Arithmetic mean of a list of [`Duration`]s. Empty lists return [`Duration::ZERO`]. -pub fn mean(runs: &[Duration]) -> Duration { - if runs.is_empty() { - return Duration::ZERO; - } - let total_nanos: u128 = runs.iter().map(|d| d.as_nanos()).sum(); - let avg_nanos = total_nanos / runs.len() as u128; - Duration::from_nanos(u64::try_from(avg_nanos).unwrap_or(u64::MAX)) -} - -/// Median of a list of [`Duration`]s. Empty lists return [`Duration::ZERO`]. -pub fn median(runs: &[Duration]) -> Duration { - if runs.is_empty() { - return Duration::ZERO; - } - let mut sorted = runs.to_vec(); - sorted.sort(); - let mid = sorted.len() / 2; - if sorted.len() % 2 == 1 { - sorted[mid] - } else { - let total_nanos = sorted[mid - 1].as_nanos() + sorted[mid].as_nanos(); - Duration::from_nanos(u64::try_from(total_nanos / 2).unwrap_or(u64::MAX)) - } -} diff --git a/deny.toml b/deny.toml index 64814519f91..b9f9c79a796 100644 --- a/deny.toml +++ b/deny.toml @@ -15,6 +15,8 @@ feature-depth = 1 ignore = [ # Paste is no longer maintained because its essentially "finished". "RUSTSEC-2024-0436", + # proc-macro-error-2 is unmaintained, only used by the `test_with` test dependency + "RUSTSEC-2026-0173" ] [licenses] diff --git a/docs/specs/index.md b/docs/specs/index.md index 547cd99f182..045ccb0710d 100644 --- a/docs/specs/index.md +++ b/docs/specs/index.md @@ -11,4 +11,5 @@ file-format ipc-format dtype-format scalar-format +row-encoding ``` diff --git a/docs/specs/row-encoding.md b/docs/specs/row-encoding.md new file mode 100644 index 00000000000..8fc3288f82b --- /dev/null +++ b/docs/specs/row-encoding.md @@ -0,0 +1,539 @@ +# Row Encoding Byte Sort Specification + +This document describes the byte-sortable row encoding implemented by the `vortex-row` +crate. The encoding converts one or more columnar arrays into a `ListView` array. Each +output row is a byte string, and lexicographic byte comparison of those byte strings matches +logical tuple comparison of the input values under the configured row sort options. + +This is a schema-aware row-key format. The bytes do not contain type tags, field names, or +sort options. Two encoded rows are comparable only when they were produced with the same +input schema and the same per-column `RowSortField` settings. + +The row encoding is not the Vortex file format or scalar IPC format. It is an internal +comparison representation used for sort keys and row-key operations. + +:::{warning} +The row encoding format is experimental. Its byte layout, supported type set, and edge-case +semantics may change between Vortex releases. Do not persist these bytes or depend on them as +a stable interchange format. +::: + +## Order Property + +For a fixed schema with columns `c0, c1, ..., cn` and per-column sort fields +`f0, f1, ..., fn`, row encoding provides this property: + +```text +encode(row_a) < encode(row_b) +``` + +if and only if tuple comparison says: + +```text +(row_a.c0, row_a.c1, ..., row_a.cn) < (row_b.c0, row_b.c1, ..., row_b.cn) +``` + +using the requested ascending or descending direction and requested null placement for each +column. + +The property is built from two rules: + +1. Each supported scalar or nested value is encoded so its bytes sort in the same order as + the value. +2. Fields are concatenated from left to right, so lexicographic byte comparison naturally + performs tuple comparison. + +## Notation + +This document uses the following notation: + +- `||` means byte concatenation. +- `BE(x)` means the fixed-width big-endian bytes of `x`. +- `!b` means `b XOR 0xFF`. +- `!bytes` means bitwise complement of every byte in `bytes`. +- `zero(n)` means `n` zero bytes. +- `ff(n)` means `n` bytes of `0xFF`. +- `width(T)` means the native byte width of fixed-width type `T`. + +`BE(x)` always emits exactly the byte width of the value being encoded, with the most +significant byte first. It is not length-prefixed and it does not drop leading zero or +leading `0xFF` bytes. The host machine's native endianness is irrelevant; encoders produce +these bytes explicitly. + +For example: + +| Value and type | `BE(value)` | +| --- | --- | +| `1_u8` | `01` | +| `258_u16` | `01 02` | +| `258_u32` | `00 00 01 02` | +| `-5_i32`, before the signed sign-bit transform | `FF FF FF FB` | +| `ordered = 0x80000000_u32` | `80 00 00 00` | + +## Field Options + +Each input column has a `RowSortField`: + +```text +RowSortField { + descending: bool, + nulls_first: bool, +} +``` + +`descending` reverses the order of non-null values. `nulls_first` is independent of +`descending`, so nulls can sort before or after non-nulls in either direction. + +## Sentinel Summary + +Sentinels are single bytes that classify nullness and, for variable-width values, whether a +value is empty or non-empty. They are chosen so byte comparison can decide those categories +before comparing any value bytes. + +| Encoding family | Case | Ascending, nulls first | Descending, nulls first | Ascending, nulls last | Descending, nulls last | +| --- | --- | --- | --- | --- | --- | +| Fixed-width | Null | `0x00` | `0x00` | `0x02` | `0x02` | +| Fixed-width | Non-null | `0x01` | `0x01` | `0x01` | `0x01` | +| Variable-width | Null | `0x00` | `0x00` | `0xFF` | `0xFF` | +| Variable-width | Empty | `0x01` | `0xFE` | `0x01` | `0xFE` | +| Variable-width | Non-empty | `0x02` | `0xFD` | `0x02` | `0xFD` | + +Fixed-width sentinels are used by null, boolean, primitive, decimal, struct, and fixed-size +list values. Variable-width sentinels are used by UTF-8 and binary values. + +## Fixed-Width Sentinels + +Every fixed-width value starts with a one-byte sentinel: + +| Case | Sentinel | +| --- | --- | +| Null, `nulls_first = true` | `0x00` | +| Non-null | `0x01` | +| Null, `nulls_first = false` | `0x02` | + +The sentinel is not inverted for descending order. Only the non-null value bytes are +inverted. This keeps null placement independent from sort direction. + +For fixed-width nulls, the sentinel is followed by zero-filled value bytes. This gives fixed +types a constant encoded width for every row. + +## Variable-Width Sentinels + +UTF-8 and binary values use three leading sentinels. The separate empty and non-empty +sentinels are important: they ensure the first byte decides null, empty, or non-empty before +later columns can affect comparison. + +| Case | Ascending | Descending | +| --- | --- | --- | +| Null, `nulls_first = true` | `0x00` | `0x00` | +| Empty | `0x01` | `0xFE` | +| Non-empty | `0x02` | `0xFD` | +| Null, `nulls_first = false` | `0xFF` | `0xFF` | + +The null sentinel is not inverted by descending order. Empty and non-empty sentinels are +inverted so non-null value order is reversed while null placement stays fixed. + +## Null + +`Null` values have no body: + +```text +fixed_null_sentinel +``` + +The sentinel is `0x00` for nulls-first and `0x02` for nulls-last. + +## Boolean + +Booleans are fixed-width and use one value byte: + +```text +sentinel || value_byte +``` + +For ascending order: + +| Value | Value byte | +| --- | --- | +| `false` | `0x01` | +| `true` | `0x02` | + +For descending order, the value byte is inverted: + +| Value | Value byte | +| --- | --- | +| `true` | `0xFD` | +| `false` | `0xFE` | + +Null booleans encode as: + +```text +null_sentinel || 0x00 +``` + +## Unsigned Integers + +Supported unsigned primitive types are `u8`, `u16`, `u32`, and `u64`. + +Ascending encoding: + +```text +0x01 || BE(value) +``` + +Descending encoding: + +```text +0x01 || !BE(value) +``` + +Big-endian byte order makes lexicographic byte order match numeric order for fixed-width +unsigned integers. Bitwise complement reverses that order for descending fields. + +Null unsigned integers encode as: + +```text +null_sentinel || zero(width(T)) +``` + +## Signed Integers + +Supported signed primitive PTypes are `i8`, `i16`, `i32`, and `i64`. The same signed +integer transform is also used for `i128` decimal storage. + +Signed integers first flip the sign bit of their big-endian two's-complement +representation: + +```text +ordered = BE(value) +ordered[0] = ordered[0] XOR 0x80 +``` + +Ascending encoding: + +```text +0x01 || ordered +``` + +Descending encoding: + +```text +0x01 || !ordered +``` + +Flipping the sign bit maps the signed numeric range into unsigned byte order: + +```text +negative values -> 0x00..0x7F prefix range +non-negative values -> 0x80..0xFF prefix range +``` + +Null signed integers encode as: + +```text +null_sentinel || zero(width(T)) +``` + +## Floating Point + +Supported floating primitive types are `f16`, `f32`, and `f64`. + +The encoder treats the IEEE bit pattern as an unsigned integer and applies a sign-aware +transform before writing big-endian bytes. + +For a floating value with raw bits `bits`: + +```text +if sign_bit(bits) == 0: + ordered = bits XOR sign_bit_mask +else: + ordered = bits XOR all_ones +``` + +Ascending encoding: + +```text +0x01 || BE(ordered) +``` + +Descending encoding: + +```text +0x01 || !BE(ordered) +``` + +This produces a total-order-style byte ordering where negative values sort before positive +values, and `-0.0` sorts before `+0.0`. NaN values are ordered by their raw bit patterns +under the same transform; they are not canonicalized by row encoding. + +Null floats encode as: + +```text +null_sentinel || zero(width(T)) +``` + +## Decimal + +Decimals are encoded as their scaled signed integer storage value. The selected storage +width is the smallest decimal value type for the decimal precision: + +| Precision | Storage | +| --- | --- | +| `1..=2` | `i8` | +| `3..=4` | `i16` | +| `5..=9` | `i32` | +| `10..=18` | `i64` | +| `19..=38` | `i128` | + +The storage integer is encoded with the signed integer encoding described above. Decimal +columns have one precision and scale, so ordering the scaled integer storage values matches +ordering the decimal values in that column. + +`Decimal256` is not supported by row encoding. + +## UTF-8 and Binary + +UTF-8 and binary values use the variable-width sentinels described above. + +Null: + +```text +varlen_null_sentinel +``` + +Empty: + +```text +varlen_empty_sentinel +``` + +Non-empty: + +```text +varlen_non_empty_sentinel || varlen_body(bytes) +``` + +For UTF-8, `bytes` are the UTF-8 bytes of the string. For binary, `bytes` are the raw binary +bytes. The byte ordering is therefore UTF-8 byte lexicographic order for strings and raw byte +lexicographic order for binary. + +### Variable-Length Body + +Non-empty variable-length values are encoded in blocks. Each block contains 32 data bytes +followed by one marker byte: + +```text +data[0..32] || marker +``` + +For ascending order: + +- Every non-final full block uses marker `0xFF`. +- The final block is padded with zeros to 32 data bytes. +- The final marker is the number of real data bytes in the final block, in `1..=32`. + +For descending order: + +- Every data byte is inverted. +- Every non-final full-block marker is `0x00`, the inverse of `0xFF`. +- The final block is padded with `0xFF`, the inverse of ascending zero padding. +- The final marker is inverted: `final_len XOR 0xFF`. + +If the input length is exactly a multiple of 32, the final block has marker `32`, and earlier +blocks, if any, use the continuation marker. + +This block structure preserves prefix order. For example, in ascending order a shorter value +that is a prefix of a longer value reaches its final marker before the longer value reaches +the continuation marker. Since final length markers in `1..=32` are less than `0xFF`, the +shorter prefix sorts first. Descending order inverts the same bytes and reverses that result. + +## Struct + +A struct is encoded as: + +```text +struct_sentinel || field_0 || field_1 || ... || field_n +``` + +The outer sentinel is the fixed-width sentinel: + +- `0x01` for a non-null struct +- `0x00` or `0x02` for a null struct, depending on null placement + +For a non-null struct, each field is encoded recursively in schema order using the same +`RowSortField` as the parent struct column. + +For a null struct, the body is canonicalized so two null parent rows produce byte-equal +output even if their physical child arrays contain different values: + +- Fixed-width children contribute their fixed-width null encoding. +- Variable-width children contribute exactly one child null sentinel byte. + +A struct has fixed row width only when all of its fields have fixed row width. If any child +is variable-width, the struct is variable-width. + +## Fixed-Size List + +A fixed-size list with `N` elements is encoded as: + +```text +list_sentinel || element_0 || element_1 || ... || element_N-1 +``` + +The outer sentinel is the fixed-width sentinel: + +- `0x01` for a non-null list +- `0x00` or `0x02` for a null list, depending on null placement + +For a non-null fixed-size list, elements are encoded recursively in element order using the +same `RowSortField` as the parent list column. + +For a null fixed-size list, the body is canonicalized: + +- Fixed-width elements contribute their fixed-width null encoding, repeated `N` times. +- Variable-width elements contribute one child null sentinel byte per element. + +A fixed-size list has fixed row width only when its element type has fixed row width. + +## Nested Values + +Nested structs and fixed-size lists apply the same rules recursively. Each nullable parent +adds its own outer sentinel. Null parents canonicalize their child body before comparison can +observe underlying child values. + +## Unsupported Types + +The current row encoder rejects types for which it does not define byte-sort semantics: + +| Type | Reason | +| --- | --- | +| Variable-size `List` | No row encoding order is defined. | +| `Variant` | No row encoding order is defined. | +| `Union` | No row encoding order is defined. | +| `Extension` | No row encoding order is defined. | +| `Decimal256` | Encoding is not implemented. | + +The absence of these encodings is intentional. Adding one requires defining both the logical +ordering and the exact byte representation that preserves that ordering. + +Temporal extensions could be added later by normalizing them to storage arrays at the +row-encoder boundary, once the supported temporal ordering contract is made explicit. + +## Size and Output Layout + +The encoded output is a `ListView`: + +```text +elements: contiguous u8 buffer containing all row bytes +offsets: per-row start offset into elements +sizes: per-row byte length +``` + +Rows are not self-describing without their `sizes`. A variable-width field can make one row +longer than another, and the enclosing `ListView` supplies the row boundary. + +The encoder computes sizes before writing bytes: + +- Fixed-width columns contribute a constant width per row. +- Variable-width columns contribute data-dependent widths per row. +- The final `sizes` array is also used as the per-row write cursor during encoding. + +## Why Concatenation Works + +For each supported field type, the field encoder is an order embedding from logical values to +byte strings: + +```text +a < b <=> encode_field(a) < encode_field(b) +a = b <=> encode_field(a) = encode_field(b) +``` + +When two rows are compared lexicographically, the first differing byte belongs to the first +field whose encoded value differs. All preceding fields have byte-equal encodings and +therefore equal logical values. The result is the same as tuple comparison. + +Variable-width fields preserve this property because their encodings are self-delimiting for +comparison: + +- Null, empty, and non-empty values differ at the first byte. +- Non-empty values use block markers to decide prefix cases before the next field can be + compared. +- Row boundaries are supplied by `ListView` sizes. + +Descending order works because complementing every byte of an equal-length order-preserving +value encoding reverses its order. The variable-width encoding also complements its sentinels, +body bytes, padding, and markers for non-null values, so the same reversal applies to strings +and binary values. Null sentinels are excluded from that reversal so null placement remains +controlled solely by `nulls_first`. + +## Example Row + +This example shows one row that contains every supported encoding family. All columns use +ascending order with nulls first. + +Schema: + +```text +( + null_col: Null, + bool_col: Bool, + uint_col: U16, + int_col: I16, + float_col: F32, + decimal_col: Decimal(precision = 9, scale = 2), + utf8_col: Utf8, + binary_col: Binary, + struct_col: Struct { x: I8, y: Utf8 }, + fsl_col: FixedSizeList, +) +``` + +Values: + +```text +( + null, + true, + 258_u16, + -5_i16, + 1.5_f32, + 123.45_decimal, // stored as 12345_i32 + "a", + DE AD BE EF, + { x: 1_i8, y: "" }, + [1_u8, 2_u8, 3_u8], +) +``` + +Encoded columns: + +| Column | Encoded bytes | +| --- | --- | +| `null_col` | `00` | +| `bool_col` | `01 02` | +| `uint_col` | `01 01 02` | +| `int_col` | `01 7F FB` | +| `float_col` | `01 BF C0 00 00` | +| `decimal_col` | `01 80 00 30 39` | +| `utf8_col` | `02 61 zero(31) 01` | +| `binary_col` | `02 DE AD BE EF zero(28) 04` | +| `struct_col` | `01 01 81 01` | +| `fsl_col` | `01 01 01 01 02 01 03` | + +The full row key is the concatenation of those byte strings in schema order: + +```text +00 +|| 01 02 +|| 01 01 02 +|| 01 7F FB +|| 01 BF C0 00 00 +|| 01 80 00 30 39 +|| 02 61 zero(31) 01 +|| 02 DE AD BE EF zero(28) 04 +|| 01 01 81 01 +|| 01 01 01 01 02 01 03 +``` + +Primitive examples here use one representative width per primitive family. Other widths use +the same transform and emit exactly `width(T)` value bytes. diff --git a/encodings/alp/benches/alp_compress.rs b/encodings/alp/benches/alp_compress.rs index 7fbe60d5997..70a566ab0f8 100644 --- a/encodings/alp/benches/alp_compress.rs +++ b/encodings/alp/benches/alp_compress.rs @@ -3,6 +3,8 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng as _; @@ -14,13 +16,14 @@ use vortex_alp::alp_encode; use vortex_alp::decompress_into_array; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::dtype::NativePType; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_buffer::buffer; +use vortex_session::VortexSession; fn main() { divan::main(); @@ -48,6 +51,9 @@ const BENCH_ARGS: &[(usize, f64, f64)] = &[ (10_000, 0.1, 1.0), ]; +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench(types = [f32, f64], args = BENCH_ARGS)] fn compress_alp(bencher: Bencher, args: (usize, f64, f64)) { let (n, fraction_patch, fraction_valid) = args; @@ -68,14 +74,9 @@ fn compress_alp(bencher: Bencher, args: (usize, f64, let values = values.freeze(); let array = PrimitiveArray::new(values, validity); - bencher.with_inputs(|| &array).bench_values(|array| { - alp_encode( - array.as_view(), - None, - &mut LEGACY_SESSION.create_execution_ctx(), - ) - .unwrap() - }) + bencher + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_values(|(array, mut ctx)| alp_encode(array.as_view(), None, &mut ctx).unwrap()) } #[divan::bench(types = [f32, f64], args = BENCH_ARGS)] @@ -102,10 +103,10 @@ fn decompress_alp(bencher: Bencher, args: (usize, f64 alp_encode( PrimitiveArray::new(Buffer::copy_from(&values), validity.clone()).as_view(), None, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap(), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_values(|(v, mut ctx)| decompress_into_array(v, &mut ctx)); @@ -143,7 +144,7 @@ fn compress_rd(bencher: Bencher, args: (usize, f64) let encoder = RDEncoder::new(primitive.as_slice::()); bencher - .with_inputs(|| (&primitive, &encoder, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&primitive, &encoder, SESSION.create_execution_ctx())) .bench_refs(|(primitive, encoder, ctx)| encoder.encode(primitive.as_view(), ctx)) } @@ -152,12 +153,9 @@ fn decompress_rd(bencher: Bencher, args: (usize, f6 let (n, fraction_patch) = args; let primitive = make_rd_array::(n, fraction_patch); let encoder = RDEncoder::new(primitive.as_slice::()); - let encoded = encoder.encode( - primitive.as_view(), - &mut LEGACY_SESSION.create_execution_ctx(), - ); + let encoded = encoder.encode(primitive.as_view(), &mut SESSION.create_execution_ctx()); bencher - .with_inputs(|| (&encoded, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&encoded, SESSION.create_execution_ctx())) .bench_refs(|(encoded, ctx)| (**encoded).clone().into_array().execute::(ctx)); } diff --git a/encodings/alp/src/alp/array.rs b/encodings/alp/src/alp/array.rs index cac93d1d27d..3f7c2ca3e6d 100644 --- a/encodings/alp/src/alp/array.rs +++ b/encodings/alp/src/alp/array.rs @@ -16,10 +16,10 @@ use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArraySlots; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::TypedArrayRef; use vortex_array::array_slots; use vortex_array::arrays::Primitive; @@ -55,14 +55,14 @@ use crate::alp::rules::RULES; pub type ALPArray = Array; impl ArrayHash for ALPData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.exponents.hash(state); self.patches_data.hash(state); } } impl ArrayEq for ALPData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.exponents == other.exponents && self.patches_data == other.patches_data } } @@ -695,22 +695,15 @@ mod tests { }) .collect(); + let mut ctx = SESSION.create_execution_ctx(); let array = PrimitiveArray::from_option_iter(values.clone()); - let encoded = alp_encode( - array.as_view(), - None, - &mut LEGACY_SESSION.create_execution_ctx(), - ) - .unwrap(); + let encoded = alp_encode(array.as_view(), None, &mut ctx).unwrap(); let slice_end = size - slice_start; let slice_len = slice_end - slice_start; let sliced_encoded = encoded.slice(slice_start..slice_end).unwrap(); - let result_canonical = { - let mut ctx = SESSION.create_execution_ctx(); - sliced_encoded.execute::(&mut ctx).unwrap() - }; + let result_canonical = sliced_encoded.execute::(&mut ctx).unwrap(); let result_primitive = result_canonical.into_primitive(); for idx in 0..slice_len { @@ -719,7 +712,7 @@ mod tests { let result_valid = result_primitive .validity() .vortex_expect("result validity should be derivable") - .is_valid(idx) + .execute_is_valid(idx, &mut ctx) .unwrap(); assert_eq!( result_valid, diff --git a/encodings/alp/src/alp_rd/array.rs b/encodings/alp/src/alp_rd/array.rs index 099b9e1a3ea..eaf98851665 100644 --- a/encodings/alp/src/alp_rd/array.rs +++ b/encodings/alp/src/alp_rd/array.rs @@ -18,11 +18,11 @@ use vortex_array::ArrayRef; use vortex_array::ArraySlots; use vortex_array::ArrayView; use vortex_array::Canonical; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; use vortex_array::LEGACY_SESSION; -use vortex_array::Precision; use vortex_array::TypedArrayRef; use vortex_array::VortexSessionExecute; use vortex_array::arrays::Primitive; @@ -75,17 +75,17 @@ pub struct ALPRDMetadata { } impl ArrayHash for ALPRDData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.left_parts_dictionary.array_hash(state, precision); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.left_parts_dictionary.array_hash(state, accuracy); self.right_bit_width.hash(state); self.patches_data.hash(state); } } impl ArrayEq for ALPRDData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { self.left_parts_dictionary - .array_eq(&other.left_parts_dictionary, precision) + .array_eq(&other.left_parts_dictionary, accuracy) && self.right_bit_width == other.right_bit_width && self.patches_data == other.patches_data } diff --git a/encodings/bytebool/src/array.rs b/encodings/bytebool/src/array.rs index 40da3809d01..722f48f7376 100644 --- a/encodings/bytebool/src/array.rs +++ b/encodings/bytebool/src/array.rs @@ -14,10 +14,10 @@ use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArraySlots; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::TypedArrayRef; use vortex_array::arrays::BoolArray; use vortex_array::buffer::BufferHandle; @@ -45,14 +45,14 @@ use crate::kernel::PARENT_KERNELS; pub type ByteBoolArray = Array; impl ArrayHash for ByteBoolData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.buffer.array_hash(state, precision); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.buffer.array_hash(state, accuracy); } } impl ArrayEq for ByteBoolData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - self.buffer.array_eq(&other.buffer, precision) + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { + self.buffer.array_eq(&other.buffer, accuracy) } } diff --git a/encodings/datetime-parts/src/array.rs b/encodings/datetime-parts/src/array.rs index e603b1ddfe5..ddc5ee52981 100644 --- a/encodings/datetime-parts/src/array.rs +++ b/encodings/datetime-parts/src/array.rs @@ -15,10 +15,10 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::array_slots; use vortex_array::arrays::Primitive; use vortex_array::arrays::TemporalArray; @@ -50,11 +50,11 @@ use crate::split_temporal; pub type DateTimePartsArray = Array; impl ArrayHash for DateTimePartsData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl ArrayEq for DateTimePartsData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } diff --git a/encodings/datetime-parts/src/canonical.rs b/encodings/datetime-parts/src/canonical.rs index 78dc8e689c1..3a46eb6f328 100644 --- a/encodings/datetime-parts/src/canonical.rs +++ b/encodings/datetime-parts/src/canonical.rs @@ -143,12 +143,11 @@ mod test { &mut ctx, )?; - assert!( - date_times - .as_array() - .validity()? - .mask_eq(&validity, &mut ctx)? - ); + assert!(date_times.as_array().validity()?.mask_eq( + &validity, + milliseconds.len(), + &mut ctx + )?); let dtype = date_times.dtype().clone(); let parts = DateTimePartsParts { @@ -163,7 +162,6 @@ mod test { .execute::(&mut ctx)?; assert_arrays_eq!(primitive_values, milliseconds); - assert!(primitive_values.validity()?.mask_eq(&validity, &mut ctx)?); Ok(()) } } diff --git a/encodings/datetime-parts/src/compress.rs b/encodings/datetime-parts/src/compress.rs index 676e7bbfd43..6caf3c9f643 100644 --- a/encodings/datetime-parts/src/compress.rs +++ b/encodings/datetime-parts/src/compress.rs @@ -103,7 +103,7 @@ mod tests { days_prim .validity() .vortex_expect("days validity should be derivable") - .mask_eq(&validity, &mut ctx) + .mask_eq(&validity, days_prim.len(), &mut ctx) .unwrap() ); let seconds_prim = seconds.execute::(&mut ctx).unwrap(); diff --git a/encodings/datetime-parts/src/compute/compare.rs b/encodings/datetime-parts/src/compute/compare.rs index faecc8452b3..9f854b0e0f9 100644 --- a/encodings/datetime-parts/src/compute/compare.rs +++ b/encodings/datetime-parts/src/compute/compare.rs @@ -202,8 +202,11 @@ fn compare_dtp( #[cfg(test)] mod test { use rstest::rstest; + use vortex_array::ArrayRef; + use vortex_array::ExecutionCtx; use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; + use vortex_array::aggregate_fn::fns::sum::sum; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::TemporalArray; use vortex_array::dtype::IntegerPType; @@ -230,12 +233,22 @@ mod test { .expect("Failed to construct DateTimePartsArray from TemporalArray") } + /// Count the true values in a boolean array using the provided execution context. + fn true_count(array: &ArrayRef, ctx: &mut ExecutionCtx) -> usize { + sum(array, ctx) + .unwrap() + .as_primitive() + .as_::() + .unwrap() + } + #[rstest] #[case(Validity::NonNullable, Validity::NonNullable)] #[case(Validity::NonNullable, Validity::AllValid)] #[case(Validity::AllValid, Validity::NonNullable)] #[case(Validity::AllValid, Validity::AllValid)] fn compare_date_time_parts_eq(#[case] lhs_validity: Validity, #[case] rhs_validity: Validity) { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let lhs = dtp_array_from_timestamp(86400i64, lhs_validity); // January 2, 1970, 00:00:00 UTC let rhs = dtp_array_from_timestamp(86400i64, rhs_validity.clone()); // January 2, 1970, 00:00:00 UTC let comparison = lhs @@ -243,14 +256,14 @@ mod test { .into_array() .binary(rhs.into_array(), Operator::Eq) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&comparison, &mut ctx), 1); let rhs = dtp_array_from_timestamp(0i64, rhs_validity); // January 1, 1970, 00:00:00 UTC let comparison = lhs .into_array() .binary(rhs.into_array(), Operator::Eq) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 0); + assert_eq!(true_count(&comparison, &mut ctx), 0); } #[rstest] @@ -259,6 +272,7 @@ mod test { #[case(Validity::AllValid, Validity::NonNullable)] #[case(Validity::AllValid, Validity::AllValid)] fn compare_date_time_parts_ne(#[case] lhs_validity: Validity, #[case] rhs_validity: Validity) { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let lhs = dtp_array_from_timestamp(86400i64, lhs_validity); // January 2, 1970, 00:00:00 UTC let rhs = dtp_array_from_timestamp(86401i64, rhs_validity.clone()); // January 2, 1970, 00:00:01 UTC let comparison = lhs @@ -266,14 +280,14 @@ mod test { .into_array() .binary(rhs.into_array(), Operator::NotEq) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&comparison, &mut ctx), 1); let rhs = dtp_array_from_timestamp(86400i64, rhs_validity); // January 2, 1970, 00:00:00 UTC let comparison = lhs .into_array() .binary(rhs.into_array(), Operator::NotEq) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 0); + assert_eq!(true_count(&comparison, &mut ctx), 0); } #[rstest] @@ -282,6 +296,7 @@ mod test { #[case(Validity::AllValid, Validity::NonNullable)] #[case(Validity::AllValid, Validity::AllValid)] fn compare_date_time_parts_lt(#[case] lhs_validity: Validity, #[case] rhs_validity: Validity) { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let lhs = dtp_array_from_timestamp(0i64, lhs_validity); // January 1, 1970, 01:00:00 UTC let rhs = dtp_array_from_timestamp(86400i64, rhs_validity); // January 2, 1970, 00:00:00 UTC @@ -289,7 +304,7 @@ mod test { .into_array() .binary(rhs.into_array(), Operator::Lt) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&comparison, &mut ctx), 1); } #[rstest] @@ -298,6 +313,7 @@ mod test { #[case(Validity::AllValid, Validity::NonNullable)] #[case(Validity::AllValid, Validity::AllValid)] fn compare_date_time_parts_gt(#[case] lhs_validity: Validity, #[case] rhs_validity: Validity) { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let lhs = dtp_array_from_timestamp(86400i64, lhs_validity); // January 2, 1970, 02:00:00 UTC let rhs = dtp_array_from_timestamp(0i64, rhs_validity); // January 1, 1970, 01:00:00 UTC @@ -305,7 +321,7 @@ mod test { .into_array() .binary(rhs.into_array(), Operator::Gt) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&comparison, &mut ctx), 1); } #[rstest] @@ -317,6 +333,7 @@ mod test { #[case] lhs_validity: Validity, #[case] rhs_validity: Validity, ) { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let temporal_array = TemporalArray::new_timestamp( PrimitiveArray::new(buffer![0i64], lhs_validity.clone()).into_array(), TimeUnit::Seconds, @@ -339,27 +356,27 @@ mod test { .into_array() .binary(rhs.clone().into_array(), Operator::Eq) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 0); + assert_eq!(true_count(&comparison, &mut ctx), 0); let comparison = lhs .clone() .into_array() .binary(rhs.clone().into_array(), Operator::NotEq) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&comparison, &mut ctx), 1); let comparison = lhs .clone() .into_array() .binary(rhs.clone().into_array(), Operator::Lt) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&comparison, &mut ctx), 1); let comparison = lhs .into_array() .binary(rhs.into_array(), Operator::Lte) .unwrap(); - assert_eq!(comparison.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&comparison, &mut ctx), 1); // `CompareOperator::Gt` and `CompareOperator::Gte` only cover the case of all lhs values // being larger. Therefore, these cases are not covered by unit tests. diff --git a/encodings/datetime-parts/src/compute/rules.rs b/encodings/datetime-parts/src/compute/rules.rs index a8b1b2eca83..bba19ceea47 100644 --- a/encodings/datetime-parts/src/compute/rules.rs +++ b/encodings/datetime-parts/src/compute/rules.rs @@ -133,10 +133,9 @@ impl ArrayParentReduceRule for DTPComparisonPushDownRule { } } - let result = - ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children, parent.len())? - .into_array() - .optimize()?; + let result = ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children)? + .into_array() + .optimize()?; Ok(Some(result)) } @@ -179,8 +178,11 @@ fn is_constant_zero(array: &ArrayRef) -> bool { #[cfg(test)] mod tests { + use vortex_array::ArrayRef; + use vortex_array::ExecutionCtx; use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; + use vortex_array::aggregate_fn::fns::sum::sum; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::TemporalArray; use vortex_array::arrays::scalar_fn::ScalarFnFactoryExt; @@ -200,6 +202,15 @@ mod tests { const SECONDS_PER_DAY: i64 = 86400; + /// Count the true values in a boolean array using the provided execution context. + fn true_count(array: &ArrayRef, ctx: &mut ExecutionCtx) -> usize { + sum(array, ctx) + .unwrap() + .as_primitive() + .as_::() + .unwrap() + } + /// Create a DTP array with the given day values (all at midnight). fn dtp_at_midnight(days: &[i64], time_unit: TimeUnit) -> DateTimePartsArray { let multiplier = match time_unit { @@ -285,7 +296,8 @@ mod tests { ); // Verify correctness: days [0, 1, 2] <= 1 should give [true, true, false] - assert_eq!(optimized.as_bool_typed().true_count().unwrap(), 2); + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + assert_eq!(true_count(&optimized, &mut ctx), 2); } #[test] @@ -313,7 +325,8 @@ mod tests { let optimized = between.optimize().unwrap(); // Verify correctness: days [0, 1, 2, 3, 4] between 1 and 3 should give [false, true, true, true, false] - assert_eq!(optimized.as_bool_typed().true_count().unwrap(), 3); + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + assert_eq!(true_count(&optimized, &mut ctx), 3); } #[test] @@ -335,7 +348,8 @@ mod tests { // (optimization doesn't apply, so we keep the original structure) // Just verify it still computes correctly // days [0, 1, 2] at midnight <= day 1 at noon: [true, true, false] - assert_eq!(optimized.as_bool_typed().true_count().unwrap(), 2); + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + assert_eq!(true_count(&optimized, &mut ctx), 2); } #[test] @@ -352,9 +366,8 @@ mod tests { TimeUnit::Seconds, None, ); - let dtp = - DateTimeParts::try_from_temporal(temporal, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let dtp = DateTimeParts::try_from_temporal(temporal, &mut ctx).unwrap(); let len = dtp.len(); // Compare against midnight constant @@ -366,6 +379,6 @@ mod tests { // Should still compute correctly (just not optimized via pushdown) let optimized = comparison.optimize().unwrap(); // timestamps at 1am on days [0, 1, 2] <= day 1 midnight: [true, false, false] - assert_eq!(optimized.as_bool_typed().true_count().unwrap(), 1); + assert_eq!(true_count(&optimized, &mut ctx), 1); } } diff --git a/encodings/decimal-byte-parts/src/decimal_byte_parts/mod.rs b/encodings/decimal-byte-parts/src/decimal_byte_parts/mod.rs index 76349f40910..42135a9ecaa 100644 --- a/encodings/decimal-byte-parts/src/decimal_byte_parts/mod.rs +++ b/encodings/decimal-byte-parts/src/decimal_byte_parts/mod.rs @@ -17,10 +17,10 @@ use vortex_array::ArrayEq; use vortex_array::ArrayHash; use vortex_array::ArrayId; use vortex_array::ArrayRef; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::TypedArrayRef; use vortex_array::arrays::DecimalArray; use vortex_array::arrays::PrimitiveArray; @@ -53,11 +53,11 @@ use crate::decimal_byte_parts::rules::PARENT_RULES; pub type DecimalBytePartsArray = Array; impl ArrayHash for DecimalBytePartsData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl ArrayEq for DecimalBytePartsData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } diff --git a/encodings/experimental/onpair/benches/decode.rs b/encodings/experimental/onpair/benches/decode.rs index 2f3fce9db23..ff8db8c22c2 100644 --- a/encodings/experimental/onpair/benches/decode.rs +++ b/encodings/experimental/onpair/benches/decode.rs @@ -213,9 +213,8 @@ fn canonicalize_to_varbinview(bencher: Bencher, case: (Shape, usize)) { let (shape, n) = case; let arr = compress(n, shape); bencher - .with_inputs(|| arr.clone().into_array()) - .bench_local_values(|arr| { - let mut ctx = SESSION.create_execution_ctx(); + .with_inputs(|| (arr.clone().into_array(), SESSION.create_execution_ctx())) + .bench_local_values(|(arr, mut ctx)| { divan::black_box( arr.execute::(&mut ctx) .unwrap_or_else(|e| panic!("canonicalize failed: {e}")), @@ -234,13 +233,14 @@ fn filter_share_dict(bencher: Bencher, case: (Shape, usize)) { let (shape, n) = case; let arr = compress(n, shape); let mask = Mask::from_iter((0..n).map(|i| i % 7 == 0)); - bencher.bench_local(|| { - let mut ctx = SESSION.create_execution_ctx(); - let result = ::filter(arr.as_view(), &mask, &mut ctx) - .unwrap() - .unwrap(); - divan::black_box(result); - }); + bencher + .with_inputs(|| SESSION.create_execution_ctx()) + .bench_local_values(|mut ctx| { + let result = ::filter(arr.as_view(), &mask, &mut ctx) + .unwrap() + .unwrap(); + divan::black_box(result); + }); } fn main() { diff --git a/encodings/experimental/onpair/src/array.rs b/encodings/experimental/onpair/src/array.rs index 18fa5e942ce..f9e18c36ac4 100644 --- a/encodings/experimental/onpair/src/array.rs +++ b/encodings/experimental/onpair/src/array.rs @@ -15,10 +15,10 @@ use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; use vortex_array::Canonical; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::array_slots; use vortex_array::buffer::BufferHandle; use vortex_array::builders::ArrayBuilder; @@ -196,19 +196,19 @@ impl Debug for OnPairData { } impl ArrayHash for OnPairData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.dict_bytes.as_host().array_hash(state, precision); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.dict_bytes.as_host().array_hash(state, accuracy); state.write_u32(self.bits); } } impl ArrayEq for OnPairData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { self.bits == other.bits && self .dict_bytes .as_host() - .array_eq(other.dict_bytes.as_host(), precision) + .array_eq(other.dict_bytes.as_host(), accuracy) } } diff --git a/encodings/experimental/onpair/src/canonical.rs b/encodings/experimental/onpair/src/canonical.rs index c658b841155..20a058e7f90 100644 --- a/encodings/experimental/onpair/src/canonical.rs +++ b/encodings/experimental/onpair/src/canonical.rs @@ -3,6 +3,8 @@ // //! Convert an [`OnPairArray`] to its canonical `VarBinViewArray` by handing //! the materialised parts to `onpair::decompress_into`. +//! +//! [`OnPairArray`]: crate::OnPairArray use std::sync::Arc; diff --git a/encodings/experimental/onpair/src/decode.rs b/encodings/experimental/onpair/src/decode.rs index e3c7346f0d9..fd0b02b0284 100644 --- a/encodings/experimental/onpair/src/decode.rs +++ b/encodings/experimental/onpair/src/decode.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors // -//! Helpers for turning [`OnPair`] slot children into the inputs the upstream +//! Helpers for turning [`OnPair`](super::OnPair) slot children into the inputs the upstream //! `onpair` decoder consumes. use vortex_array::ArrayRef; diff --git a/encodings/experimental/onpair/tests/big_data.rs b/encodings/experimental/onpair/tests/big_data.rs index 81b1fa9e10b..c6bc163ea9f 100644 --- a/encodings/experimental/onpair/tests/big_data.rs +++ b/encodings/experimental/onpair/tests/big_data.rs @@ -18,6 +18,7 @@ use std::time::Instant; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::accessor::ArrayAccessor; +use vortex_array::aggregate_fn::fns::sum::sum; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::VarBinArray; use vortex_array::arrays::VarBinViewArray; @@ -121,6 +122,11 @@ fn smoke_100k_rows() { .execute::(&mut ctx) .unwrap() .into_array(); - assert_eq!(eq.as_bool_typed().true_count().unwrap(), want_eq); + let eq_count = sum(&eq, &mut ctx) + .unwrap() + .as_primitive() + .as_::() + .unwrap(); + assert_eq!(eq_count, want_eq); eprintln!("eq pushdown matches reference count ({})", want_eq); } diff --git a/encodings/fastlanes/Cargo.toml b/encodings/fastlanes/Cargo.toml index 08c96c481d7..e0aa5cbb724 100644 --- a/encodings/fastlanes/Cargo.toml +++ b/encodings/fastlanes/Cargo.toml @@ -64,6 +64,10 @@ required-features = ["_test-harness"] name = "bitpack_compare" harness = false +[[bench]] +name = "bitpack_compare_sweep" +harness = false + [[bench]] name = "cast_bitpacked" harness = false diff --git a/encodings/fastlanes/benches/bitpack_compare.rs b/encodings/fastlanes/benches/bitpack_compare.rs index 939c401ee38..c704f2b35ed 100644 --- a/encodings/fastlanes/benches/bitpack_compare.rs +++ b/encodings/fastlanes/benches/bitpack_compare.rs @@ -10,33 +10,39 @@ #![expect(clippy::unwrap_used)] #![expect(clippy::cast_possible_truncation)] +use std::sync::LazyLock; + use divan::Bencher; use divan::counter::ItemsCount; use vortex_array::ArrayRef; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::builtins::ArrayBuiltins; use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::BufferMut; use vortex_fastlanes::BitPackedData; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + const LENS: &[usize] = &[1024, 64 * 1024]; const BIT_WIDTHS: &[u8] = &[4, 16]; /// Build a packed array of varied in-range values, plus an out-of-range constant RHS for /// the fast-path benches. fn build_inputs(len: usize) -> (ArrayRef, ArrayRef, ExecutionCtx) { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let buf: BufferMut = (0..len).map(|i| (i as u32) % (1 << BW)).collect(); let array = BitPackedData::encode( &PrimitiveArray::new(buf.freeze(), Validity::NonNullable).into_array(), diff --git a/encodings/fastlanes/benches/bitpack_compare_sweep.rs b/encodings/fastlanes/benches/bitpack_compare_sweep.rs new file mode 100644 index 00000000000..0be525566e7 --- /dev/null +++ b/encodings/fastlanes/benches/bitpack_compare_sweep.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Sweeps the public `BitPackedArray` compare-against-constant path (`array.binary(rhs, op)`) over +//! every integer type and every valid bit width, so a kernel change shows up as a CodSpeed diff. +//! +//! The array holds in-range values (no patches, no out-of-range fast path), so each iteration runs +//! the full unpack + per-element compare kernel that backs ``. +//! +//! Run with `cargo bench -p vortex-fastlanes --bench bitpack_compare_sweep`. + +#![expect(clippy::unwrap_used)] +#![expect(clippy::cast_possible_truncation)] + +use std::sync::LazyLock; + +use divan::Bencher; +use divan::counter::ItemsCount; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::NativePType; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::session::ArraySession; +use vortex_array::validity::Validity; +use vortex_buffer::BufferMut; +use vortex_fastlanes::BitPackedData; +use vortex_session::VortexSession; + +fn main() { + divan::main(); +} + +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + +/// Number of elements per benchmarked array (64 full FastLanes blocks). +const LEN: usize = 64 * 1024; + +/// Operator under test. `Lt` exercises the full unpack + per-element comparison path. +const OP: Operator = Operator::Lt; + +/// Integer types we can build packed arrays for in the benchmark. +trait BenchInt: NativePType + Copy + Into { + /// Build an in-range value from a small counter. + fn from_counter(v: u64) -> Self; +} + +macro_rules! impl_bench_int { + ($($T:ty),+) => { + $(impl BenchInt for $T { + #[inline] + fn from_counter(v: u64) -> Self { + v as $T + } + })+ + }; +} + +impl_bench_int!(u8, u16, u32, u64, i8, i16, i32, i64); + +/// Encode `LEN` in-range values of type `T` at the given bit width, returning the packed array, a +/// mid-range constant to compare against, and an execution context. +fn setup(width: usize) -> (ArrayRef, ArrayRef, ExecutionCtx) { + let mut ctx = SESSION.create_execution_ctx(); + let cap = 1u64 << width; + let buf: BufferMut = (0..LEN) + .map(|i| T::from_counter((i as u64) % cap)) + .collect(); + let array = BitPackedData::encode( + &PrimitiveArray::new(buf.freeze(), Validity::NonNullable).into_array(), + width as u8, + &mut ctx, + ) + .unwrap() + .into_array(); + let rhs = ConstantArray::new(T::from_counter(cap / 2), LEN).into_array(); + (array, rhs, ctx) +} + +/// Generate a compare benchmark over every valid bit width for one type. Valid widths are +/// `1..native_bits` - bit-packing requires the target width to be strictly narrower than the type. +macro_rules! bench_type { + ($modname:ident, $T:ty, $native_bits:expr) => { + mod $modname { + use super::*; + + #[divan::bench(args = 1..$native_bits)] + fn compare(bencher: Bencher, width: usize) { + let (array, rhs, mut ctx) = setup::<$T>(width); + bencher.counter(ItemsCount::new(LEN)).bench_local(|| { + array + .clone() + .binary(rhs.clone(), OP) + .unwrap() + .execute::(&mut ctx) + .unwrap() + }); + } + } + }; +} + +bench_type!(u8, u8, 8usize); +bench_type!(u16, u16, 16usize); +bench_type!(u32, u32, 32usize); +bench_type!(u64, u64, 64usize); +bench_type!(i8, i8, 8usize); +bench_type!(i16, i16, 16usize); +bench_type!(i32, i32, 32usize); +bench_type!(i64, i64, 64usize); diff --git a/encodings/fastlanes/benches/bitpacking_take.rs b/encodings/fastlanes/benches/bitpacking_take.rs index a5a7803bcaf..2e01f1253e6 100644 --- a/encodings/fastlanes/benches/bitpacking_take.rs +++ b/encodings/fastlanes/benches/bitpacking_take.rs @@ -4,37 +4,42 @@ #![expect(clippy::unwrap_used)] #![expect(clippy::cast_possible_truncation)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::distr::Uniform; use rand::prelude::StdRng; use vortex_array::IntoArray as _; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_buffer::buffer; use vortex_fastlanes::BitPackedArrayExt; use vortex_fastlanes::bitpack_compress::bitpack_to_best_bit_width; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench] fn take_10_stratified(bencher: Bencher) { let values = fixture(65_536, 8); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = PrimitiveArray::from_iter((0..10).map(|i| i * 6_553)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -49,12 +54,11 @@ fn take_10_contiguous(bencher: Bencher) { let values = fixture(65_536, 8); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = buffer![0..10].into_array(); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone()) @@ -70,14 +74,13 @@ fn take_10k_random(bencher: Bencher) { let range = Uniform::new(0, values.len()).unwrap(); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let rng = StdRng::seed_from_u64(0); let indices = PrimitiveArray::from_iter(rng.sample_iter(range).take(10_000).map(|i| i as u32)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -92,12 +95,11 @@ fn take_10k_contiguous(bencher: Bencher) { let values = fixture(65_536, 8); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = PrimitiveArray::from_iter(0..10_000); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -112,12 +114,11 @@ fn take_10k_dispersed(bencher: Bencher) { let values = fixture(65_536, 8); let uncompressed = PrimitiveArray::new(values.clone(), Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = PrimitiveArray::from_iter((0..10_000).map(|i| (i * 42) % values.len() as u64)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -132,12 +133,11 @@ fn take_10k_first_chunk_only(bencher: Bencher) { let values = fixture(65_536, 8); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = PrimitiveArray::from_iter((0..10_000).map(|i| ((i * 42) % 1024) as u64)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -172,8 +172,7 @@ fn patched_take_10_stratified(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); assert!(packed.patches().is_some()); assert_eq!( @@ -184,7 +183,7 @@ fn patched_take_10_stratified(bencher: Bencher) { let indices = PrimitiveArray::from_iter((0..10).map(|i| i * 6_653)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -199,8 +198,7 @@ fn patched_take_10_contiguous(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); assert!(packed.patches().is_some()); assert_eq!( @@ -211,7 +209,7 @@ fn patched_take_10_contiguous(bencher: Bencher) { let indices = buffer![0..10].into_array(); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone()) @@ -226,15 +224,14 @@ fn patched_take_10k_random(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values.clone(), Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let rng = StdRng::seed_from_u64(0); let range = Uniform::new(0, values.len()).unwrap(); let indices = PrimitiveArray::from_iter(rng.sample_iter(range).take(10_000).map(|i| i as u32)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -249,12 +246,11 @@ fn patched_take_10k_contiguous_not_patches(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = PrimitiveArray::from_iter((0u32..NUM_EXCEPTIONS).cycle().take(10000)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -269,8 +265,7 @@ fn patched_take_10k_contiguous_patches(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); assert!(packed.patches().is_some()); assert_eq!( @@ -282,7 +277,7 @@ fn patched_take_10k_contiguous_patches(bencher: Bencher) { PrimitiveArray::from_iter((BIG_BASE2..BIG_BASE2 + NUM_EXCEPTIONS).cycle().take(10000)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -297,12 +292,11 @@ fn patched_take_10k_dispersed(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values.clone(), Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = PrimitiveArray::from_iter((0..10_000).map(|i| (i * 42) % values.len() as u64)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -317,12 +311,11 @@ fn patched_take_10k_first_chunk_only(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let indices = PrimitiveArray::from_iter((0..10_000).map(|i| ((i * 42) % 1024) as u64)); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) @@ -337,8 +330,7 @@ fn patched_take_10k_adversarial(bencher: Bencher) { let values = (0u32..BIG_BASE2 + NUM_EXCEPTIONS).collect::>(); let uncompressed = PrimitiveArray::new(values, Validity::NonNullable); let packed = - bitpack_to_best_bit_width(&uncompressed, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + bitpack_to_best_bit_width(&uncompressed, &mut SESSION.create_execution_ctx()).unwrap(); let per_chunk_count = 100; let indices = PrimitiveArray::from_iter( (0..(NUM_EXCEPTIONS + 1024) / 1024) @@ -349,7 +341,7 @@ fn patched_take_10k_adversarial(bencher: Bencher) { ); bencher - .with_inputs(|| (&packed, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&packed, &indices, SESSION.create_execution_ctx())) .bench_refs(|(packed, indices, execution_ctx)| { packed .take(indices.clone().into_array()) diff --git a/encodings/fastlanes/benches/compute_between.rs b/encodings/fastlanes/benches/compute_between.rs index 1630764ff8e..5a1bdcee655 100644 --- a/encodings/fastlanes/benches/compute_between.rs +++ b/encodings/fastlanes/benches/compute_between.rs @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use num_traits::NumCast; use rand::RngExt; use rand::rngs::StdRng; @@ -11,17 +13,21 @@ use vortex_alp::ALPArraySlotsExt; use vortex_alp::alp_encode; use vortex_array::ArrayRef; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::dtype::NativePType; +use vortex_array::session::ArraySession; use vortex_error::VortexExpect; use vortex_fastlanes::bitpack_compress::bitpack_to_best_bit_width; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + fn generate_primitive_array( rng: &mut StdRng, len: usize, @@ -39,7 +45,7 @@ fn generate_bit_pack_primitive_array( .map(|_| T::from_usize(rng.random_range(0..10_000)).vortex_expect("")) .collect::(); - bitpack_to_best_bit_width(&a, &mut LEGACY_SESSION.create_execution_ctx()) + bitpack_to_best_bit_width(&a, &mut SESSION.create_execution_ctx()) .vortex_expect("") .into_array() } @@ -52,7 +58,7 @@ fn generate_alp_bit_pack_primitive_array( .map(|_| T::from_usize(rng.random_range(0..10_000)).vortex_expect("")) .collect::(); - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let alp = alp_encode(a.as_view(), None, &mut ctx).vortex_expect(""); let encoded = alp @@ -75,7 +81,6 @@ mod primitive { use rand::SeedableRng; use rand::prelude::StdRng; use vortex_array::IntoArray; - use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ConstantArray; @@ -86,6 +91,7 @@ mod primitive { use vortex_error::VortexExpect; use crate::BENCH_ARGS; + use crate::SESSION; use crate::generate_primitive_array; #[divan::bench( @@ -103,7 +109,7 @@ mod primitive { let arr = generate_primitive_array::(&mut rng, len); bencher - .with_inputs(|| (&arr, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&arr, SESSION.create_execution_ctx())) .bench_refs(|(arr, ctx)| { arr.clone() .into_array() @@ -128,7 +134,6 @@ mod bitpack { use rand::SeedableRng; use rand::prelude::StdRng; use vortex_array::IntoArray; - use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ConstantArray; @@ -139,6 +144,7 @@ mod bitpack { use vortex_error::VortexExpect; use crate::BENCH_ARGS; + use crate::SESSION; use crate::generate_bit_pack_primitive_array; #[divan::bench( @@ -156,7 +162,7 @@ mod bitpack { let arr = generate_bit_pack_primitive_array::(&mut rng, len); bencher - .with_inputs(|| (&arr, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&arr, SESSION.create_execution_ctx())) .bench_refs(|(arr, ctx)| { arr.clone() .between( @@ -180,7 +186,6 @@ mod alp { use rand::SeedableRng; use rand::prelude::StdRng; use vortex_array::IntoArray; - use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ConstantArray; @@ -191,6 +196,7 @@ mod alp { use vortex_error::VortexExpect; use crate::BENCH_ARGS; + use crate::SESSION; use crate::generate_alp_bit_pack_primitive_array; #[divan::bench( @@ -208,7 +214,7 @@ mod alp { let arr = generate_alp_bit_pack_primitive_array::(&mut rng, len); bencher - .with_inputs(|| (&arr, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&arr, SESSION.create_execution_ctx())) .bench_refs(|(arr, ctx)| { arr.clone() .between( diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs index 2e517819059..eba5be1b749 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs @@ -273,15 +273,13 @@ where let total_chunks = data.len().div_ceil(1024); let mut chunk_offsets: BufferMut = BufferMut::with_capacity(total_chunks); - for (idx, value) in data.iter().enumerate() { + for ((idx, value), valid) in data.iter().enumerate().zip(validity_mask.iter()) { if (idx % 1024) == 0 { // Record the patch index offset for each chunk. chunk_offsets.push(values.len() as u64); } - if (value.leading_zeros() as usize) < T::PTYPE.bit_width() - bit_width as usize - && validity_mask.value(idx) - { + if (value.leading_zeros() as usize) < T::PTYPE.bit_width() - bit_width as usize && valid { indices.push(P::from(idx).vortex_expect("cast index from usize")); values.push(*value); } diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs index 5b81580d7a6..a4f8224966a 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs @@ -113,7 +113,7 @@ where // SAFETY: We initialize all `len` values below via `decode` and the patch loop. unsafe { - uninit_range.append_mask(array.validity()?.execute_mask(len, ctx)?); + uninit_range.append_mask(&array.validity()?.execute_mask(len, ctx)?); } // SAFETY: `decode` writes a value to every slot in this range. diff --git a/encodings/fastlanes/src/bitpacking/array/unpack_iter.rs b/encodings/fastlanes/src/bitpacking/array/unpack_iter.rs index 5e0673186e3..c9158d8bdac 100644 --- a/encodings/fastlanes/src/bitpacking/array/unpack_iter.rs +++ b/encodings/fastlanes/src/bitpacking/array/unpack_iter.rs @@ -259,6 +259,34 @@ impl> UnpackedChunks { debug_assert_eq!(local_idx, self.len); } + /// Walk every *packed* chunk in array order, yielding the raw packed FastLanes block and the + /// padded bit range it covers, without unpacking it. + /// + /// Unlike [`Self::for_each_unpacked_chunk`], this does not fill the scratch buffer: it hands + /// the still-packed block to the callback so fused kernels (e.g. compare) can unpack and + /// consume it in a single pass. Each yielded block holds exactly `elems_per_chunk` packed + /// values (the buffer is zero-padded out to a whole final chunk). + /// + /// The yielded range is in *padded* coordinates: block `c` covers + /// `[c * 1024, min((c + 1) * 1024, offset + len))`, so it includes the leading `offset` rows + /// that slicing skips. Block starts are therefore always 1024-aligned regardless of `offset`. + /// Callers must account for the array's `offset` when mapping a block's rows back to logical + /// output positions (e.g. by viewing the output buffer at a bit offset of `offset`). + pub(crate) fn for_each_packed_chunk(&self, mut f: F) + where + F: FnMut(&[T::Physical], Range), + { + let packed_slice: &[T::Physical] = buffer_as_slice(&self.packed); + let elems_per_chunk = self.elems_per_chunk(); + let padded_len = self.offset + self.len; + for chunk in 0..self.num_chunks { + let packed_chunk = &packed_slice[chunk * elems_per_chunk..][..elems_per_chunk]; + let start = chunk * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(padded_len); + f(packed_chunk, start..end); + } + } + /// Unpack full chunks into output range starting at the given index. fn decode_full_chunks_into_at( &mut self, diff --git a/encodings/fastlanes/src/bitpacking/compute/between.rs b/encodings/fastlanes/src/bitpacking/compute/between.rs index 0611d111125..dcc372ad091 100644 --- a/encodings/fastlanes/src/bitpacking/compute/between.rs +++ b/encodings/fastlanes/src/bitpacking/compute/between.rs @@ -6,6 +6,8 @@ //! Reuses the same single-block scratch buffer as the compare kernel and folds a //! `lower op_l v op_u upper` predicate per element, so the full primitive never //! materialises. +//! +//! [`BitPackedArray`]: crate::BitPackedArray use vortex_array::ArrayRef; use vortex_array::ArrayView; diff --git a/encodings/fastlanes/src/bitpacking/compute/compare.rs b/encodings/fastlanes/src/bitpacking/compute/compare.rs index f5c5c81c5cb..e622e64a665 100644 --- a/encodings/fastlanes/src/bitpacking/compute/compare.rs +++ b/encodings/fastlanes/src/bitpacking/compute/compare.rs @@ -7,12 +7,19 @@ //! block at a time through a reusable scratch buffer, and a per-element bool is folded into //! a [`BitBuffer`]. Patches are re-applied at the end by overwriting bits at the patched //! indices with `predicate(patch_value)`. +//! +//! [`BitPackedArray`]: crate::BitPackedArray +//! [`BitBuffer`]: vortex_buffer::BitBuffer +use fastlanes::BitPacking; +use fastlanes::BitPackingCompare; +use fastlanes::FastLanesComparable; use vortex_array::ArrayRef; use vortex_array::ArrayView; use vortex_array::ExecutionCtx; use vortex_array::dtype::NativePType; use vortex_array::dtype::Nullability; +use vortex_array::dtype::PhysicalPType; use vortex_array::match_each_integer_ptype; use vortex_array::scalar_fn::fns::binary::CompareKernel; use vortex_array::scalar_fn::fns::operators::CompareOperator; @@ -20,7 +27,8 @@ use vortex_error::VortexExpect; use vortex_error::VortexResult; use crate::BitPacked; -use crate::bitpacking::compute::stream_predicate::stream_predicate; +use crate::bitpacking::compute::compare_fused::stream_compare_fused; +use crate::unpack_iter::BitPacked as BitPackedIter; impl CompareKernel for BitPacked { fn compare( @@ -55,6 +63,10 @@ impl CompareKernel for BitPacked { } } +/// Compare every value against the constant via the fused FastLanes `unpack_cmp` kernel. +/// +/// `NativePType::is_eq` / `is_lt` etc. provide total comparison (matching the primitive between +/// kernel's dispatch shape). `NotEq` has no direct method, so use `!is_eq`. fn compare_constant_typed( lhs: ArrayView<'_, BitPacked>, rhs: T, @@ -63,42 +75,60 @@ fn compare_constant_typed( ctx: &mut ExecutionCtx, ) -> VortexResult where - T: NativePType + Copy + crate::unpack_iter::BitPacked, + T: NativePType + + BitPackedIter + + FastLanesComparable::Physical>, + ::Physical: BitPacking + NativePType + BitPackingCompare, { - // `NativePType::is_eq` / `is_lt` etc. provide total comparison (matching the primitive - // between kernel's dispatch shape). `NotEq` has no direct method, so use `!is_eq`. match operator { - CompareOperator::Eq => stream_predicate::(lhs, nullability, |v| v.is_eq(rhs), ctx), + CompareOperator::Eq => { + stream_compare_fused::(lhs, rhs, nullability, |a, b| a.is_eq(b), ctx) + } CompareOperator::NotEq => { - stream_predicate::(lhs, nullability, |v| !v.is_eq(rhs), ctx) + stream_compare_fused::(lhs, rhs, nullability, |a, b| !a.is_eq(b), ctx) + } + CompareOperator::Lt => { + stream_compare_fused::(lhs, rhs, nullability, |a, b| a.is_lt(b), ctx) + } + CompareOperator::Lte => { + stream_compare_fused::(lhs, rhs, nullability, |a, b| a.is_le(b), ctx) + } + CompareOperator::Gt => { + stream_compare_fused::(lhs, rhs, nullability, |a, b| a.is_gt(b), ctx) + } + CompareOperator::Gte => { + stream_compare_fused::(lhs, rhs, nullability, |a, b| a.is_ge(b), ctx) } - CompareOperator::Lt => stream_predicate::(lhs, nullability, |v| v.is_lt(rhs), ctx), - CompareOperator::Lte => stream_predicate::(lhs, nullability, |v| v.is_le(rhs), ctx), - CompareOperator::Gt => stream_predicate::(lhs, nullability, |v| v.is_gt(rhs), ctx), - CompareOperator::Gte => stream_predicate::(lhs, nullability, |v| v.is_ge(rhs), ctx), } } #[cfg(test)] mod tests { + use std::sync::LazyLock; + use rstest::rstest; use vortex_array::IntoArray; - use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::PrimitiveArray; + use vortex_array::arrays::slice::SliceKernel; use vortex_array::assert_arrays_eq; use vortex_array::builtins::ArrayBuiltins; use vortex_array::scalar_fn::fns::binary::CompareKernel; use vortex_array::scalar_fn::fns::operators::CompareOperator; use vortex_array::scalar_fn::fns::operators::Operator; + use vortex_array::session::ArraySession; use vortex_error::VortexResult; + use vortex_session::VortexSession; use crate::BitPacked; use crate::BitPackedArrayExt; use crate::BitPackedData; + static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + /// All six operators on a small in-range input. #[rstest] #[case(Operator::Eq, vec![false, false, false, true, false, false, true])] @@ -108,7 +138,7 @@ mod tests { #[case(Operator::Gt, vec![false, false, false, false, true, true, false])] #[case(Operator::Gte, vec![false, false, false, true, true, true, true])] fn small(#[case] op: Operator, #[case] expected: Vec) { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let values = PrimitiveArray::from_iter([0u32, 1, 2, 3, 4, 5, 3]); let packed = BitPackedData::encode(&values.into_array(), 3, &mut ctx).unwrap(); let rhs = ConstantArray::new(3u32, packed.len()).into_array(); @@ -130,7 +160,7 @@ mod tests { ($name:ident, $T:ty, $($bw:expr),+) => { #[test] fn $name() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); for bw in [$($bw),+] { let cap: u128 = 1u128 << bw; let values: Vec<$T> = (0..2048u128).map(|i| (i % cap) as $T).collect(); @@ -171,7 +201,7 @@ mod tests { /// predicate runs. #[test] fn signed_with_patches_matches_primitive() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let values: Vec = (0..1500) .map(|i| if i % 73 == 0 { 100_000 + i } else { i % 100 }) .collect(); @@ -191,10 +221,91 @@ mod tests { Ok(()) } + /// Sliced inputs: a non-zero block offset (and a length spanning several blocks) must still go + /// through the fused kernel and agree with the primitive fallback. Sweeps slice starts that + /// land both inside the first block and past it, with lengths that end mid-block and on a block + /// boundary. + #[rstest] + #[case(1, 4000)] // start mid-first-block, multi-block length + #[case(1023, 2)] // start at the last row of the first block + #[case(1024, 1024)] // start exactly on a block boundary, exactly one block long + #[case(1500, 1000)] // start mid-second-block + #[case(3, 1021)] // ends exactly on the first block boundary + fn sliced_matches_primitive( + #[case] start: usize, + #[case] slice_len: usize, + ) -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); + let values: Vec = (0..5000u32).map(|i| i % 128).collect(); + let prim = PrimitiveArray::from_iter(values); + let packed = BitPackedData::encode(&prim.clone().into_array(), 7, &mut ctx)?; + + let sliced = packed.into_array().slice(start..start + slice_len)?; + let rhs = ConstantArray::new(50u32, slice_len).into_array(); + for op in [ + CompareOperator::Eq, + CompareOperator::Lt, + CompareOperator::Gte, + ] { + let got = ::compare( + sliced.as_::(), + &rhs, + op, + &mut ctx, + )? + .expect("fused compare kernel must engage for sliced arrays") + .execute::(&mut ctx)?; + let want = prim + .clone() + .into_array() + .slice(start..start + slice_len)? + .binary(rhs.clone(), Operator::from(op))? + .execute::(&mut ctx)?; + assert_arrays_eq!(got, want); + } + Ok(()) + } + + /// Sliced *and* patched: combine a non-zero offset with out-of-range values that land in + /// `Patches`, exercising the `offset + (global - p_off)` patch-position math. + #[test] + fn sliced_with_patches_matches_primitive() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); + let values: Vec = (0..4096) + .map(|i| if i % 91 == 0 { 100_000 + i } else { i % 100 }) + .collect(); + let prim = PrimitiveArray::from_iter(values); + let packed = BitPackedData::encode(&prim.clone().into_array(), 7, &mut ctx)?; + assert!(packed.patches().is_some(), "test setup expects patches"); + + let (start, end) = (700usize, 3500usize); + // `ArrayRef::slice` leaves a lazy `SliceArray` over a patched `BitPacked` (the + // `SliceReduce` path bails when patches are present), so go through the `SliceKernel`, + // which reads the buffers and produces a sliced `BitPacked` with sliced patches. + let sliced = ::slice(packed.as_view(), start..end, &mut ctx)? + .expect("slice kernel produces a sliced bitpacked array"); + let rhs = ConstantArray::new(50i32, end - start).into_array(); + let got = ::compare( + sliced.as_::(), + &rhs, + CompareOperator::Eq, + &mut ctx, + )? + .expect("fused compare kernel must engage for sliced arrays with patches") + .execute::(&mut ctx)?; + let want = prim + .into_array() + .slice(start..end)? + .binary(rhs, Operator::Eq)? + .execute::(&mut ctx)?; + assert_arrays_eq!(got, want); + Ok(()) + } + /// Nullable input — the result must carry the array's validity. #[test] fn nullable_propagates_validity() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let prim = PrimitiveArray::from_option_iter([Some(1u32), None, Some(3), Some(4), None]); let packed = BitPackedData::encode(&prim.clone().into_array(), 3, &mut ctx)?; let rhs = ConstantArray::new(3u32, packed.len()).into_array(); diff --git a/encodings/fastlanes/src/bitpacking/compute/compare_fused.rs b/encodings/fastlanes/src/bitpacking/compute/compare_fused.rs new file mode 100644 index 00000000000..baf3b0ede96 --- /dev/null +++ b/encodings/fastlanes/src/bitpacking/compute/compare_fused.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Fused compare kernel for [`BitPackedArray`] against a constant. +//! +//! Where [`super::stream_predicate`] unpacks a full 1024-element FastLanes block into a scratch +//! buffer and *then* folds a predicate over it, this path hands the comparison down into the +//! FastLanes [`BitPackingCompare::unchecked_unpack_cmp`] kernel, which compares each value against +//! the constant *as it is unpacked*, accumulating the boolean results straight into a 1024-bit +//! mask (`[u64; 16]`) in transposed FastLanes lane order - one register-resident word per lane, no +//! `[bool; 1024]` or `[T; 1024]` scratch. A single SIMD [`untranspose_bits`] per block then rotates +//! that mask into logical row order. +//! +//! The packed blocks are walked through the regular [`crate::unpack_iter::BitUnpackedChunks`] +//! iterator (via [`crate::unpack_iter::BitUnpackedChunks::for_each_packed_chunk`]) rather than a +//! bespoke chunk loop, so chunk sizing and bounds live in one place. +//! +//! Slicing is handled by working in *padded* coordinates: bit `offset + i` holds element `i`. The +//! output buffer is over-allocated to whole 1024-bit blocks, so every block - the sliced first +//! block, the body, and the trailing partial - untransposes straight into a 64-bit-word-aligned +//! slot with no per-block temporary and only one shared scratch `[u64; 16]`. The leading `offset` +//! garbage rows are represented as the final [`BitBuffer`] bit offset, which naturally handles +//! sub-byte slices without copy-aligning. Inline patches are spliced in afterwards by overwriting +//! the bits at the patched indices with `cmp(patch_value, rhs)`. +//! +//! [`BitPackedArray`]: crate::BitPackedArray +//! [`BitBuffer`]: vortex_buffer::BitBuffer + +use fastlanes::BitPacking; +use fastlanes::BitPackingCompare; +use fastlanes::FastLanesComparable; +use fastlanes::untranspose_bits; +use num_traits::AsPrimitive; +use vortex_array::ArrayRef; +use vortex_array::ArrayView; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::dtype::NativePType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PhysicalPType; +use vortex_array::match_each_unsigned_integer_ptype; +use vortex_buffer::BitBufferMut; +use vortex_buffer::BufferMut; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; + +use super::stream_predicate::stream_predicate; +use crate::BitPacked; +use crate::BitPackedArrayExt; +use crate::unpack_iter::BitPacked as BitPackedIter; + +const CHUNK_SIZE: usize = 1024; +const U64_BITS: usize = u64::BITS as usize; +/// `u64` words spanning one FastLanes block (1024 bits / 64). +const WORDS_PER_CHUNK: usize = CHUNK_SIZE / U64_BITS; + +/// Compare the unpacked values of a [`BitPackedArray`] against `rhs` using the fused FastLanes +/// `unpack_cmp` kernel, producing a [`BoolArray`]. +/// +/// `cmp(value, rhs)` defines the predicate; it must be the total-order comparison matching the +/// requested operator (e.g. `|a, b| a.is_lt(b)`). +/// +/// [`BitPackedArray`]: crate::BitPackedArray +pub(super) fn stream_compare_fused( + array: ArrayView<'_, BitPacked>, + rhs: T, + nullability: Nullability, + cmp: F, + ctx: &mut ExecutionCtx, +) -> VortexResult +where + T: NativePType + + BitPackedIter + + FastLanesComparable::Physical>, + ::Physical: BitPacking + NativePType + BitPackingCompare, + F: Fn(T, T) -> bool + Copy, +{ + let len = array.len(); + let bit_width = array.bit_width() as usize; + let offset = array.offset() as usize; + + // A degenerate width has no packed payload for the fused kernel to consume; defer to the scalar + // streaming predicate, which handles every layout (including the empty array). + if len == 0 || bit_width == 0 { + return stream_predicate::(array, nullability, move |v| cmp(v, rhs), ctx); + } + + // Over-allocate to whole 1024-bit blocks in padded coordinates so every block - including the + // trailing partial - has room for a full untranspose at a 64-bit-word-aligned offset. + let num_chunks = (offset + len).div_ceil(CHUNK_SIZE); + let mut words: BufferMut = BufferMut::zeroed(num_chunks * WORDS_PER_CHUNK); + + let chunks = array.unpacked_chunks::()?; + { + let words = words.as_mut_slice(); + let mut transposed = [0u64; WORDS_PER_CHUNK]; + chunks.for_each_packed_chunk(|packed_chunk, range| { + // Block starts are always 1024-aligned (padded coords), so the slot is a full block. + let out = words[range.start / U64_BITS..] + .first_chunk_mut::() + .vortex_expect("over-allocated buffer holds a full block per chunk"); + // SAFETY: `packed_chunk` holds exactly `128 * bit_width / size_of::()` packed + // elements and `bit_width <= U::T`, satisfying `unchecked_unpack_cmp`'s contract. The + // kernel assigns every word in `transposed`, so its previous contents are irrelevant. + unsafe { + <::Physical as BitPackingCompare>::unchecked_unpack_cmp::( + bit_width, + packed_chunk, + &mut transposed, + cmp, + rhs, + ); + } + untranspose_bits::<::Physical>(&transposed, out); + }); + } + + let mut bits = BitBufferMut::from_buffer(words.into_byte_buffer(), offset, len); + + // Patched indices hold placeholder packed values, so their fused result is meaningless; + // overwrite each with the comparison against the real patch value. + // TODO(joe): apply patches per `packed_chunked`. + if let Some(p) = array.patches() { + let p_idx = p.indices().clone().execute::(ctx)?; + // TODO(joe): push down cmp?? + let p_val = p.values().clone().execute::(ctx)?; + let p_off = p.offset(); + match_each_unsigned_integer_ptype!(p_idx.ptype(), |I| { + let indices = p_idx.as_slice::(); + let values = p_val.as_slice::(); + for (&global, &value) in indices.iter().zip(values) { + let global: usize = global.as_(); + let idx = global - p_off; + bits.set_to(idx, cmp(value, rhs)) + } + }); + } + + let validity = array.validity()?.union_nullability(nullability); + Ok(BoolArray::new(bits.freeze(), validity).into_array()) +} diff --git a/encodings/fastlanes/src/bitpacking/compute/filter.rs b/encodings/fastlanes/src/bitpacking/compute/filter.rs index 06e50e09bf0..284ae5bce4c 100644 --- a/encodings/fastlanes/src/bitpacking/compute/filter.rs +++ b/encodings/fastlanes/src/bitpacking/compute/filter.rs @@ -28,7 +28,7 @@ use crate::BitPacked; use crate::BitPackedArrayExt; use crate::BitPackedData; -/// The threshold over which it is faster to fully unpack the entire [`BitPackedArray`] and then +/// The threshold over which it is faster to fully unpack the entire [`BitPackedArray`](crate::BitPackedArray) and then /// filter the result than to unpack only specific bitpacked values into the output buffer. pub const fn unpack_then_filter_threshold(ptype: PType) -> f64 { // TODO(connor): Where did these numbers come from? Add a public link after validating them. diff --git a/encodings/fastlanes/src/bitpacking/compute/mod.rs b/encodings/fastlanes/src/bitpacking/compute/mod.rs index 518f8319eb1..06a4b4597b0 100644 --- a/encodings/fastlanes/src/bitpacking/compute/mod.rs +++ b/encodings/fastlanes/src/bitpacking/compute/mod.rs @@ -4,6 +4,7 @@ mod between; mod cast; mod compare; +mod compare_fused; mod filter; pub(crate) mod is_constant; mod slice; diff --git a/encodings/fastlanes/src/bitpacking/compute/stream_predicate.rs b/encodings/fastlanes/src/bitpacking/compute/stream_predicate.rs index 4ed9de913ec..a85cc358c60 100644 --- a/encodings/fastlanes/src/bitpacking/compute/stream_predicate.rs +++ b/encodings/fastlanes/src/bitpacking/compute/stream_predicate.rs @@ -4,12 +4,16 @@ //! Streaming, cache-reusable predicate evaluation over a [`BitPackedArray`]. //! //! Walks the encoded array one 1024-element FastLanes block at a time through a single -//! reusable scratch buffer, splices any [`crate::patches::Patches`] into the unpacked block +//! reusable scratch buffer, splices any [`Patches`] into the unpacked block //! in place via a sorted-index cursor, then folds a `Fn(T) -> bool` predicate over the -//! block. The fold matches the canonical [`vortex_buffer::BitBuffer::collect_bool`] shape +//! block. The fold matches the canonical [`BitBuffer::collect_bool`] shape //! (pack 64 bools into a `u64` in a tight auto-vectorisable inner loop) and writes the //! resulting words straight into the output bit buffer, so the materialised primitive //! never appears anywhere. +//! +//! [`BitPackedArray`]: crate::BitPackedArray +//! [`BitBuffer::collect_bool`]: vortex_buffer::BitBuffer::collect_bool +//! [`Patches`]: vortex_array::patches::Patches use num_traits::AsPrimitive; use vortex_array::ArrayRef; @@ -30,7 +34,7 @@ use crate::BitPacked; use crate::BitPackedArrayExt; use crate::unpack_iter::BitPacked as BitPackedIter; -/// Stream `predicate` over the unpacked values of a [`BitPackedArray`], one FastLanes +/// Stream `predicate` over the unpacked values of a [`BitPackedArray`](crate::BitPackedArray), one FastLanes /// block at a time, producing a [`BoolArray`]. pub(super) fn stream_predicate( array: ArrayView<'_, BitPacked>, diff --git a/encodings/fastlanes/src/bitpacking/compute/take.rs b/encodings/fastlanes/src/bitpacking/compute/take.rs index a4c148c5717..bbf5bfaf2c8 100644 --- a/encodings/fastlanes/src/bitpacking/compute/take.rs +++ b/encodings/fastlanes/src/bitpacking/compute/take.rs @@ -30,7 +30,7 @@ use crate::bitpack_decompress; // TODO(connor): This is duplicated in `encodings/fastlanes/src/bitpacking/kernels/mod.rs`. /// assuming the buffer is already allocated (which will happen at most once) then unpacking /// all 1024 elements takes ~8.8x as long as unpacking a single element on an M2 Macbook Air. -/// see https://github.com/vortex-data/vortex/pull/190#issue-2223752833 +/// see pub(super) const UNPACK_CHUNK_THRESHOLD: usize = 8; impl TakeExecute for BitPacked { diff --git a/encodings/fastlanes/src/bitpacking/vtable/mod.rs b/encodings/fastlanes/src/bitpacking/vtable/mod.rs index 912dd4ff44b..52b536e02c5 100644 --- a/encodings/fastlanes/src/bitpacking/vtable/mod.rs +++ b/encodings/fastlanes/src/bitpacking/vtable/mod.rs @@ -13,10 +13,10 @@ use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArraySlots; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::buffer::BufferHandle; use vortex_array::builders::ArrayBuilder; use vortex_array::dtype::DType; @@ -69,19 +69,19 @@ pub struct BitPackedMetadata { } impl ArrayHash for BitPackedData { - fn array_hash(&self, state: &mut H, precision: Precision) { + fn array_hash(&self, state: &mut H, accuracy: EqMode) { self.offset.hash(state); self.bit_width.hash(state); - self.packed.array_hash(state, precision); + self.packed.array_hash(state, accuracy); self.patches_data.hash(state); } } impl ArrayEq for BitPackedData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { self.offset == other.offset && self.bit_width == other.bit_width - && self.packed.array_eq(&other.packed, precision) + && self.packed.array_eq(&other.packed, accuracy) && self.patches_data == other.patches_data } } diff --git a/encodings/fastlanes/src/delta/vtable/mod.rs b/encodings/fastlanes/src/delta/vtable/mod.rs index b5e68791ceb..dfb440620ab 100644 --- a/encodings/fastlanes/src/delta/vtable/mod.rs +++ b/encodings/fastlanes/src/delta/vtable/mod.rs @@ -12,10 +12,10 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::arrays::PrimitiveArray; use vortex_array::buffer::BufferHandle; use vortex_array::dtype::DType; @@ -58,13 +58,13 @@ pub struct DeltaMetadata { } impl ArrayHash for DeltaData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.offset.hash(state); } } impl ArrayEq for DeltaData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.offset == other.offset } } diff --git a/encodings/fastlanes/src/for/array/for_decompress.rs b/encodings/fastlanes/src/for/array/for_decompress.rs index 073864411fb..ffe9449f962 100644 --- a/encodings/fastlanes/src/for/array/for_decompress.rs +++ b/encodings/fastlanes/src/for/array/for_decompress.rs @@ -109,7 +109,7 @@ pub(crate) fn fused_decompress< let mut uninit_range = builder.uninit_range(bp.len()); unsafe { // Append a dense null Mask. - uninit_range.append_mask(bp.validity()?.execute_mask(bp.as_ref().len(), ctx)?); + uninit_range.append_mask(&bp.validity()?.execute_mask(bp.as_ref().len(), ctx)?); } // SAFETY: `decode_into` will initialize all values in this range. diff --git a/encodings/fastlanes/src/for/vtable/mod.rs b/encodings/fastlanes/src/for/vtable/mod.rs index 899276341de..037a858892e 100644 --- a/encodings/fastlanes/src/for/vtable/mod.rs +++ b/encodings/fastlanes/src/for/vtable/mod.rs @@ -12,10 +12,10 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::arrays::PrimitiveArray; use vortex_array::buffer::BufferHandle; use vortex_array::dtype::DType; @@ -50,13 +50,13 @@ mod validity; pub type FoRArray = Array; impl ArrayHash for FoRData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.reference.hash(state); } } impl ArrayEq for FoRData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.reference == other.reference } } diff --git a/encodings/fastlanes/src/rle/array/rle_compress.rs b/encodings/fastlanes/src/rle/array/rle_compress.rs index e48f31f51da..107c912c23f 100644 --- a/encodings/fastlanes/src/rle/array/rle_compress.rs +++ b/encodings/fastlanes/src/rle/array/rle_compress.rs @@ -440,7 +440,7 @@ mod tests { let mut rng_state: u32 = 0xDEAD_BEEF; let validity = indices_prim.validity()?; for (i, idx) in indices_data.iter_mut().enumerate() { - if !validity.is_valid(i).unwrap_or(true) { + if !validity.execute_is_valid(i, ctx).unwrap_or(true) { // xorshift32 rng_state ^= rng_state << 13; rng_state ^= rng_state >> 17; diff --git a/encodings/fastlanes/src/rle/vtable/mod.rs b/encodings/fastlanes/src/rle/vtable/mod.rs index 989b462ab99..82c236ae810 100644 --- a/encodings/fastlanes/src/rle/vtable/mod.rs +++ b/encodings/fastlanes/src/rle/vtable/mod.rs @@ -12,10 +12,10 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::arrays::Primitive; use vortex_array::buffer::BufferHandle; use vortex_array::dtype::DType; @@ -65,13 +65,13 @@ pub struct RLEMetadata { } impl ArrayHash for RLEData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.offset.hash(state); } } impl ArrayEq for RLEData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.offset == other.offset } } diff --git a/encodings/fsst/benches/fsst_compress.rs b/encodings/fsst/benches/fsst_compress.rs index 45aedfa464f..88e4e26a903 100644 --- a/encodings/fsst/benches/fsst_compress.rs +++ b/encodings/fsst/benches/fsst_compress.rs @@ -11,7 +11,6 @@ use rand::SeedableRng; use rand::rngs::StdRng; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ChunkedArray; @@ -57,7 +56,7 @@ fn compress_fsst(bencher: Bencher, (string_count, avg_len, unique_chars): (usize let array = generate_test_data(string_count, avg_len, unique_chars); let compressor = fsst_train_compressor(&array); bencher - .with_inputs(|| (&array, &compressor, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&array, &compressor, SESSION.create_execution_ctx())) .bench_refs(|(array, compressor, ctx)| { fsst_compress(*array, array.len(), array.dtype(), compressor, ctx) }) @@ -74,11 +73,11 @@ fn decompress_fsst(bencher: Bencher, (string_count, avg_len, unique_chars): (usi len, &dtype, &compressor, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ); bencher - .with_inputs(|| (&encoded, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&encoded, SESSION.create_execution_ctx())) .bench_refs(|(encoded, ctx)| (**encoded).clone().into_array().execute::(ctx)) } @@ -99,18 +98,12 @@ fn pushdown_compare(bencher: Bencher, (string_count, avg_len, unique_chars): (us array.len(), array.dtype(), &compressor, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ); let constant = ConstantArray::new(Scalar::from(&b"const"[..]), array.len()); bencher - .with_inputs(|| { - ( - &fsst_array, - &constant, - LEGACY_SESSION.create_execution_ctx(), - ) - }) + .with_inputs(|| (&fsst_array, &constant, SESSION.create_execution_ctx())) .bench_refs(|(fsst_array, constant, ctx)| { fsst_array .clone() @@ -134,18 +127,12 @@ fn canonicalize_compare( array.len(), array.dtype(), &compressor, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ); let constant = ConstantArray::new(Scalar::from(&b"const"[..]), array.len()); bencher - .with_inputs(|| { - ( - &fsst_array, - &constant, - LEGACY_SESSION.create_execution_ctx(), - ) - }) + .with_inputs(|| (&fsst_array, &constant, SESSION.create_execution_ctx())) .bench_refs(|(fsst_array, constant, ctx)| { (*fsst_array) .clone() @@ -235,7 +222,7 @@ fn generate_chunked_test_data( avg_len: usize, unique_chars: u8, ) -> ChunkedArray { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); (0..chunk_size) .map(|_| { let array = generate_test_data(string_count, avg_len, unique_chars); diff --git a/encodings/fsst/src/array.rs b/encodings/fsst/src/array.rs index 617908e94dd..83360a69e64 100644 --- a/encodings/fsst/src/array.rs +++ b/encodings/fsst/src/array.rs @@ -6,7 +6,7 @@ use std::fmt::Display; use std::fmt::Formatter; use std::hash::Hasher; use std::sync::Arc; -use std::sync::LazyLock; +use std::sync::OnceLock; use fsst::Compressor; use fsst::Decompressor; @@ -21,11 +21,11 @@ use vortex_array::ArrayRef; use vortex_array::ArraySlots; use vortex_array::ArrayView; use vortex_array::Canonical; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; use vortex_array::LEGACY_SESSION; -use vortex_array::Precision; use vortex_array::TypedArrayRef; use vortex_array::VortexSessionExecute; use vortex_array::arrays::VarBin; @@ -80,19 +80,24 @@ impl FSSTMetadata { } impl ArrayHash for FSSTData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.symbols.array_hash(state, precision); - self.symbol_lengths.array_hash(state, precision); + fn array_hash(&self, state: &mut H, precision: EqMode) { + self.symbol_table.symbols.array_hash(state, precision); + self.symbol_table + .symbol_lengths + .array_hash(state, precision); self.codes_bytes.as_host().array_hash(state, precision); } } impl ArrayEq for FSSTData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - self.symbols.array_eq(&other.symbols, precision) + fn array_eq(&self, other: &Self, precision: EqMode) -> bool { + self.symbol_table + .symbols + .array_eq(&other.symbol_table.symbols, precision) && self + .symbol_table .symbol_lengths - .array_eq(&other.symbol_lengths, precision) + .array_eq(&other.symbol_table.symbol_lengths, precision) && self .codes_bytes .as_host() @@ -346,28 +351,29 @@ pub(crate) const SLOT_NAMES: [&str; NUM_SLOTS] = /// [`FSSTArrayExt::codes()`], combining this buffer with the offsets/validity from slots. #[derive(Clone)] pub struct FSSTData { - symbols: Buffer, - symbol_lengths: Buffer, + symbol_table: Arc, /// The raw compressed codes bytes, equivalent to `VarBinData::bytes`. codes_bytes: BufferHandle, /// Cached length (number of elements). len: usize, - - /// Memoized compressor used for push-down of compute by compressing the RHS. - compressor: Arc Compressor + Send>>>, } impl Display for FSSTData { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "len: {}, nsymbols: {}", self.len, self.symbols.len()) + write!( + f, + "len: {}, nsymbols: {}", + self.len, + self.symbol_table.symbols.len() + ) } } impl Debug for FSSTData { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("FSSTArray") - .field("symbols", &self.symbols) - .field("symbol_lengths", &self.symbol_lengths) + .field("symbols", &self.symbol_table.symbols) + .field("symbol_lengths", &self.symbol_table.symbol_lengths) .field("codes_bytes_len", &self.codes_bytes.len()) .field("len", &self.len) .field("uncompressed_lengths", &"") @@ -377,6 +383,29 @@ impl Debug for FSSTData { } } +pub(crate) struct FSSTSymbolTable { + symbols: Buffer, + symbol_lengths: Buffer, + /// Memoized compressor used for push-down of compute by compressing the RHS. + compressor: OnceLock, +} + +impl FSSTSymbolTable { + fn new(symbols: Buffer, symbol_lengths: Buffer) -> Self { + Self { + symbols, + symbol_lengths, + compressor: OnceLock::new(), + } + } + + fn compressor(&self) -> &Compressor { + self.compressor.get_or_init(|| { + Compressor::rebuild_from(self.symbols.as_slice(), self.symbol_lengths.as_slice()) + }) + } +} + #[derive(Clone, Debug)] pub struct FSST; @@ -412,6 +441,32 @@ impl FSST { }) } + pub(crate) fn try_new_with_symbol_table( + dtype: DType, + symbol_table: Arc, + codes: VarBinArray, + uncompressed_lengths: ArrayRef, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let len = codes.len(); + FSSTData::validate_parts_from_codes( + &symbol_table.symbols, + &symbol_table.symbol_lengths, + &codes, + &uncompressed_lengths, + &dtype, + len, + ctx, + )?; + let slots = FSSTData::make_slots(&codes, &uncompressed_lengths); + let codes_bytes = codes.bytes_handle().clone(); + let data = + unsafe { FSSTData::new_unchecked_with_symbol_table(symbol_table, codes_bytes, len) }; + Ok(unsafe { + Array::from_parts_unchecked(ArrayParts::new(FSST, dtype, len, data).with_slots(slots)) + }) + } + /// Legacy deserialization path (2 buffers): the codes were stored as a full /// `VarBinArray` child. We decompose the VarBinArray into its bytes (stored in /// FSSTData) and offsets/validity (stored in slots). @@ -463,17 +518,17 @@ impl FSST { Ok(ArrayParts::new(self.clone(), dtype.clone(), len, data).with_slots(slots)) } - pub(crate) unsafe fn new_unchecked( + pub(crate) unsafe fn new_unchecked_with_symbol_table( dtype: DType, - symbols: Buffer, - symbol_lengths: Buffer, + symbol_table: Arc, codes: VarBinArray, uncompressed_lengths: ArrayRef, ) -> FSSTArray { let len = codes.len(); let slots = FSSTData::make_slots(&codes, &uncompressed_lengths); let codes_bytes = codes.bytes_handle().clone(); - let data = unsafe { FSSTData::new_unchecked(symbols, symbol_lengths, codes_bytes, len) }; + let data = + unsafe { FSSTData::new_unchecked_with_symbol_table(symbol_table, codes_bytes, len) }; unsafe { Array::from_parts_unchecked(ArrayParts::new(FSST, dtype, len, data).with_slots(slots)) } @@ -534,8 +589,8 @@ impl FSSTData { .as_ref() .vortex_expect("FSSTArray codes_offsets slot"); Self::validate_parts( - &self.symbols, - &self.symbol_lengths, + &self.symbol_table.symbols, + &self.symbol_table.symbol_lengths, &self.codes_bytes, codes_offsets, dtype.nullability(), @@ -567,10 +622,13 @@ impl FSSTData { if symbols.len() > 255 { vortex_bail!(InvalidArgument: "symbols array must have length <= 255"); } + if symbols.len() != symbol_lengths.len() { vortex_bail!(InvalidArgument: "symbols and symbol_lengths arrays must have same length"); } + Self::validate_symbol_lengths(symbol_lengths.as_slice())?; + // codes_offsets.len() - 1 == number of elements let codes_len = codes_offsets.len().saturating_sub(1); if codes_len != len { @@ -612,6 +670,32 @@ impl FSSTData { Ok(()) } + fn validate_symbol_lengths(symbol_lengths: &[u8]) -> VortexResult<()> { + let mut expected = 2; + for (idx, &len) in symbol_lengths.iter().enumerate() { + if len > 8 || len == 0 { + vortex_bail!(InvalidArgument: "symbol length at index {idx} must be between 1 and 8, found {len}"); + } + + if expected == 1 { + if len != 1 { + vortex_bail!(InvalidArgument: "symbol length at index {idx} must be 1 after one-byte symbols begin, found {len}"); + } + } else { + if len == 1 { + expected = 1; + } + + if len < expected { + vortex_bail!(InvalidArgument: "symbol length at index {idx} violates FSST symbol table ordering"); + } + expected = len; + } + } + + Ok(()) + } + /// Validate using a VarBinArray for the codes (convenience for construction paths). fn validate_parts_from_codes( symbols: &Buffer, @@ -641,18 +725,19 @@ impl FSSTData { codes_bytes: BufferHandle, len: usize, ) -> Self { - let symbols2 = symbols.clone(); - let symbol_lengths2 = symbol_lengths.clone(); - let compressor = Arc::new(LazyLock::new(Box::new(move || { - Compressor::rebuild_from(symbols2.as_slice(), symbol_lengths2.as_slice()) - }) - as Box Compressor + Send>)); + let symbol_table = Arc::new(FSSTSymbolTable::new(symbols, symbol_lengths)); + unsafe { Self::new_unchecked_with_symbol_table(symbol_table, codes_bytes, len) } + } + + pub(crate) unsafe fn new_unchecked_with_symbol_table( + symbol_table: Arc, + codes_bytes: BufferHandle, + len: usize, + ) -> Self { Self { - symbols, - symbol_lengths, + symbol_table, codes_bytes, len, - compressor, } } @@ -668,12 +753,16 @@ impl FSSTData { /// Access the symbol table array. pub fn symbols(&self) -> &Buffer { - &self.symbols + &self.symbol_table.symbols } /// Access the symbol lengths array. pub fn symbol_lengths(&self) -> &Buffer { - &self.symbol_lengths + &self.symbol_table.symbol_lengths + } + + pub(crate) fn symbol_table(&self) -> Arc { + Arc::clone(&self.symbol_table) } /// Access the compressed codes bytes buffer handle (may be on host or device). @@ -686,7 +775,7 @@ impl FSSTData { self.codes_bytes.as_host() } - /// Build a [`Decompressor`][fsst::Decompressor] that can be used to decompress values from + /// Build a [`Decompressor`] that can be used to decompress values from /// this array. pub fn decompressor(&self) -> Decompressor<'_> { Decompressor::new(self.symbols().as_slice(), self.symbol_lengths().as_slice()) @@ -694,7 +783,7 @@ impl FSSTData { /// Retrieves the FSST compressor. pub fn compressor(&self) -> &Compressor { - self.compressor.as_ref() + self.symbol_table.compressor() } } @@ -771,12 +860,48 @@ mod test { use vortex_array::test_harness::check_metadata; use vortex_buffer::Buffer; use vortex_error::VortexError; + use vortex_error::VortexResult; + use vortex_error::vortex_err; use crate::FSST; use crate::array::FSSTArrayExt; use crate::array::FSSTMetadata; use crate::fsst_compress_iter; + #[test] + fn slice_reuses_initialized_compressor() -> VortexResult<()> { + let symbols = Buffer::::copy_from([ + Symbol::from_slice(b"abc00000"), + Symbol::from_slice(b"defghijk"), + ]); + let symbol_lengths = Buffer::::copy_from([3, 8]); + + let compressor = Compressor::rebuild_from(symbols.as_slice(), symbol_lengths.as_slice()); + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let fsst_array = fsst_compress_iter( + [ + Some(b"abcabcab".as_ref()), + Some(b"defghijk".as_ref()), + Some(b"abcxyz".as_ref()), + ] + .into_iter(), + 3, + DType::Utf8(Nullability::NonNullable), + &compressor, + &mut ctx, + ); + + let compressor_ptr = fsst_array.compressor() as *const Compressor; + let sliced = fsst_array + .slice(1..3)? + .try_downcast::() + .map_err(|_| vortex_err!("slice must return an FSST array"))?; + let sliced_compressor_ptr = sliced.compressor() as *const Compressor; + + assert_eq!(compressor_ptr, sliced_compressor_ptr); + Ok(()) + } + #[cfg_attr(miri, ignore)] #[test] fn test_fsst_metadata() { diff --git a/encodings/fsst/src/compute/byte_length.rs b/encodings/fsst/src/compute/byte_length.rs new file mode 100644 index 00000000000..d78bd8be0ec --- /dev/null +++ b/encodings/fsst/src/compute/byte_length.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +use vortex_array::ArrayRef; +use vortex_array::ArrayView; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::ValidityVTable; +use vortex_array::arrays::ConstantArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::PType; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::fns::byte_length::ByteLengthKernel; +use vortex_array::validity::Validity; +use vortex_error::VortexResult; + +use crate::FSST; +use crate::array::FSSTArrayExt; + +impl ByteLengthKernel for FSST { + fn byte_length( + array: ArrayView<'_, Self>, + _ctx: &mut ExecutionCtx, + ) -> VortexResult> { + let nullable = array.dtype().nullability(); + let dtype = DType::Primitive(PType::U64, nullable); + // Uncompressed lengths are non-nullable and may be less than u64 each + let lengths = array.uncompressed_lengths().cast(dtype.clone())?; + Ok(Some(match FSST::validity(array)? { + Validity::NonNullable | Validity::AllValid => lengths, + Validity::Array(v) => lengths.mask(v)?, + Validity::AllInvalid => { + ConstantArray::new(Scalar::null(dtype), lengths.len()).into_array() + } + })) + } +} diff --git a/encodings/fsst/src/compute/cast.rs b/encodings/fsst/src/compute/cast.rs index a1c96363ba0..47c324fc2a4 100644 --- a/encodings/fsst/src/compute/cast.rs +++ b/encodings/fsst/src/compute/cast.rs @@ -30,10 +30,9 @@ fn build_with_codes_validity( )?; Ok(unsafe { - FSST::new_unchecked( + FSST::new_unchecked_with_symbol_table( dtype.clone(), - array.symbols().clone(), - array.symbol_lengths().clone(), + array.symbol_table(), new_codes, array.uncompressed_lengths().clone(), ) diff --git a/encodings/fsst/src/compute/filter.rs b/encodings/fsst/src/compute/filter.rs index 74e32cfbd02..eae72cac8dd 100644 --- a/encodings/fsst/src/compute/filter.rs +++ b/encodings/fsst/src/compute/filter.rs @@ -31,10 +31,9 @@ impl FilterKernel for FSST { .vortex_expect("must be VarBin"); Ok(Some( - FSST::try_new( + FSST::try_new_with_symbol_table( array.dtype().clone(), - array.symbols().clone(), - array.symbol_lengths().clone(), + array.symbol_table(), filtered_codes, array.uncompressed_lengths().filter(mask.clone())?, ctx, diff --git a/encodings/fsst/src/compute/mod.rs b/encodings/fsst/src/compute/mod.rs index 02efdf7febc..d95725b9fbc 100644 --- a/encodings/fsst/src/compute/mod.rs +++ b/encodings/fsst/src/compute/mod.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +mod byte_length; mod cast; mod compare; mod filter; @@ -28,13 +29,12 @@ impl TakeExecute for FSST { ctx: &mut ExecutionCtx, ) -> VortexResult> { Ok(Some( - FSST::try_new( + FSST::try_new_with_symbol_table( array .dtype() .clone() .union_nullability(indices.dtype().nullability()), - array.symbols().clone(), - array.symbol_lengths().clone(), + array.symbol_table(), { let codes = array.codes(); let codes = codes.as_view(); diff --git a/encodings/fsst/src/kernel.rs b/encodings/fsst/src/kernel.rs index 7f455a06e16..386da3b0f60 100644 --- a/encodings/fsst/src/kernel.rs +++ b/encodings/fsst/src/kernel.rs @@ -5,6 +5,7 @@ use vortex_array::arrays::dict::TakeExecuteAdaptor; use vortex_array::arrays::filter::FilterExecuteAdaptor; use vortex_array::kernel::ParentKernelSet; use vortex_array::scalar_fn::fns::binary::CompareExecuteAdaptor; +use vortex_array::scalar_fn::fns::byte_length::ByteLengthExecuteAdaptor; use vortex_array::scalar_fn::fns::cast::CastExecuteAdaptor; use vortex_array::scalar_fn::fns::like::LikeExecuteAdaptor; @@ -16,6 +17,7 @@ pub(super) const PARENT_KERNELS: ParentKernelSet = ParentKernelSet::new(&[ ParentKernelSet::lift(&FilterExecuteAdaptor(FSST)), ParentKernelSet::lift(&TakeExecuteAdaptor(FSST)), ParentKernelSet::lift(&LikeExecuteAdaptor(FSST)), + ParentKernelSet::lift(&ByteLengthExecuteAdaptor(FSST)), ]); #[cfg(test)] @@ -27,10 +29,13 @@ mod tests { use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::FilterArray; + use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::varbin::builder::VarBinBuilder; use vortex_array::assert_arrays_eq; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; + use vortex_array::expr::byte_length; + use vortex_array::expr::root; use vortex_array::session::ArraySession; use vortex_error::VortexResult; use vortex_mask::Mask; @@ -205,4 +210,24 @@ mod tests { assert_arrays_eq!(result, fsst_array); Ok(()) } + + #[test] + fn test_fsst_byte_length() -> VortexResult<()> { + let mut builder = VarBinBuilder::::with_capacity(3); + builder.append_value(b"hello"); + builder.append_value(b"world!!"); + builder.append_value("Пуховички"); // 9 characters, 18 bytes + builder.append_value(b""); + + let varbin = builder.finish(DType::Utf8(Nullability::NonNullable)); + let compressor = fsst_train_compressor(&varbin); + let len = varbin.len(); + let dtype = varbin.dtype().clone(); + let mut ctx = SESSION.create_execution_ctx(); + let fsst = fsst_compress(varbin, len, &dtype, &compressor, &mut ctx).into_array(); + let result = fsst.apply(&byte_length(root()))?; + let expected = PrimitiveArray::from_iter(vec![5u64, 7, 18, 0]); + assert_arrays_eq!(result, expected); + Ok(()) + } } diff --git a/encodings/fsst/src/slice.rs b/encodings/fsst/src/slice.rs index 9b7b2833d85..b98f5fa03b3 100644 --- a/encodings/fsst/src/slice.rs +++ b/encodings/fsst/src/slice.rs @@ -19,10 +19,9 @@ impl SliceReduce for FSST { // SAFETY: slicing the `codes` leaves the symbol table intact Ok(Some( unsafe { - FSST::new_unchecked( + FSST::new_unchecked_with_symbol_table( array.dtype().clone(), - array.symbols().clone(), - array.symbol_lengths().clone(), + array.symbol_table(), array .codes() .slice(range.clone())? diff --git a/encodings/fsst/src/tests.rs b/encodings/fsst/src/tests.rs index c4dca57c61b..481b84865df 100644 --- a/encodings/fsst/src/tests.rs +++ b/encodings/fsst/src/tests.rs @@ -1,9 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use rand::SeedableRng; -use rand::rngs::StdRng; -use rand::seq::IndexedRandom; +use fsst::CompressorBuilder; use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::LEGACY_SESSION; @@ -118,12 +116,17 @@ fn test_fsst_array_ops() { /// [`VarBinBuilder`] for the FSST output and panicked in /// [`VarBinBuilder::append_value`] once cumulative compressed bytes crossed the boundary. /// -/// The input is built with [`VarBinBuilder`] so the input itself does not panic, which -/// confirms the overflow is on the FSST output side. After the fix the test must succeed -/// with the row count preserved. +/// The compressor is built with an empty symbol table, so every input byte misses the table +/// and is escape-coded at exactly two output bytes per input byte. This makes the output +/// size deterministic and crosses the i32 boundary with roughly half the input bytes that +/// an incompressible-input construction would need. The input is built with +/// [`VarBinBuilder`] so the input itself does not panic, which confirms the overflow +/// is on the FSST output side. After the fix the test must succeed with the row count +/// preserved. /// -/// Allocates ~2.5 GiB for the input and ~2.5 GiB for the FSST output (~5 GiB total), so it -/// is gated to CI runs and skipped when `VORTEX_SKIP_SLOW_TESTS` is set. To run it locally: +/// Allocates ~1.1 GiB for the input and ~2.1 GiB for the FSST output (~3.2 GiB total), so +/// it is gated to CI runs and skipped when `VORTEX_SKIP_SLOW_TESTS` is set. To run it +/// locally: /// /// ```text /// CI=1 cargo test --release -p vortex-fsst fsst_compress_offsets @@ -133,33 +136,21 @@ fn test_fsst_array_ops() { #[test_with::env(CI)] #[test_with::no_env(VORTEX_SKIP_SLOW_TESTS)] fn fsst_compress_offsets_overflow_i32() { - // High-entropy ASCII strings sliced from a random pool. FSST is a symbol-table - // compressor; pseudo-random data with no recurring byte sequences resists compression, - // so the compressed output stays close to input size and crosses the i32 boundary. const STRING_LEN: usize = 64 * 1024; - const TOTAL_BYTES: usize = (1usize << 31) + (512 << 20); // ~2.5 GiB + // Escape coding doubles every byte, so ~1.06 GiB of input compresses to ~2.13 GiB, + // comfortably past i32::MAX. + const TOTAL_BYTES: usize = (1usize << 30) + (64 << 20); const N: usize = TOTAL_BYTES / STRING_LEN; - const POOL_LEN: usize = 64 * 1024 * 1024; - - // Printable ASCII alphabet so the result is valid UTF-8. - const ALPHABET: &[u8; 95] = - b" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; - - let mut rng = StdRng::seed_from_u64(0xC0DE_C011_B711); - let pool: Vec = (0..POOL_LEN) - .map(|_| *ALPHABET.choose(&mut rng).unwrap()) - .collect(); println!("building large VarBinArray"); + let string = vec![b'a'; STRING_LEN]; let mut builder = VarBinBuilder::::with_capacity(N); - for i in 0..N { - let off = i.wrapping_mul(31337) % (POOL_LEN - STRING_LEN); - builder.append_value(&pool[off..off + STRING_LEN]); + for _ in 0..N { + builder.append_value(&string); } let array = builder.finish(DType::Utf8(Nullability::NonNullable)); - println!("training FSST compressor"); - let compressor = fsst_train_compressor(&array); + let compressor = CompressorBuilder::default().build(); let len = array.len(); let dtype = array.dtype().clone(); let mut ctx = LEGACY_SESSION.create_execution_ctx(); @@ -167,4 +158,6 @@ fn fsst_compress_offsets_overflow_i32() { println!("compressing to FSST"); let compressed = fsst_compress(array, len, &dtype, &compressor, &mut ctx); assert_eq!(compressed.len(), len); + // Prove the regression condition was exercised: compressed bytes crossed i32::MAX. + assert!(compressed.codes_bytes().len() > i32::MAX as usize); } diff --git a/encodings/parquet-variant/Cargo.toml b/encodings/parquet-variant/Cargo.toml index 980af8a75a1..6b3900abb27 100644 --- a/encodings/parquet-variant/Cargo.toml +++ b/encodings/parquet-variant/Cargo.toml @@ -12,7 +12,6 @@ readme = { workspace = true } repository = { workspace = true } rust-version = { workspace = true } version = { workspace = true } -publish = false [lints] workspace = true diff --git a/encodings/parquet-variant/src/array.rs b/encodings/parquet-variant/src/array.rs index d7004af4ef8..37247430c37 100644 --- a/encodings/parquet-variant/src/array.rs +++ b/encodings/parquet-variant/src/array.rs @@ -241,7 +241,7 @@ fn logical_shredded_from_parquet_field( let validity = field_struct.validity()?; // `unmasked_field_by_name_opt` intentionally ignores the parent struct validity. // Reapply it here so null wrapper rows become null typed/raw rows downstream. - let typed_value = if validity.no_nulls() { + let typed_value = if validity.definitely_no_nulls() { typed_value.clone() } else { typed_value @@ -251,7 +251,7 @@ fn logical_shredded_from_parquet_field( let value = field_struct .unmasked_field_by_name_opt("value") .map(|value| { - if validity.no_nulls() { + if validity.definitely_no_nulls() { Ok(value.clone()) } else { value.clone().mask(validity.to_array(value.len())) diff --git a/encodings/parquet-variant/src/arrow.rs b/encodings/parquet-variant/src/arrow.rs index 9af18c86ebb..9bee5c738d3 100644 --- a/encodings/parquet-variant/src/arrow.rs +++ b/encodings/parquet-variant/src/arrow.rs @@ -18,6 +18,8 @@ use vortex_array::ArrayRef; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::VTable; +use vortex_array::arrays::Variant; +use vortex_array::arrays::variant::VariantArrayExt; use vortex_array::arrow::ArrowExport; use vortex_array::arrow::ArrowExportVTable; use vortex_array::arrow::ArrowImport; @@ -113,6 +115,35 @@ fn export_unshredded_storage_to_target( export_storage_to_target(&unshredded_parquet, target_fields, ctx) } +fn parquet_variant_for_export(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + let executed = array.execute_until::(ctx)?; + if executed.is::() { + return Ok(executed); + } + + let variant = executed + .as_opt::() + .ok_or_else(|| vortex_err!("cannot export Variant without ParquetVariant storage"))?; + let core_storage = variant + .core_storage() + .clone() + .execute_until::(ctx)?; + let parquet_core = core_storage + .as_opt::() + .ok_or_else(|| vortex_err!("cannot export Variant without ParquetVariant core storage"))?; + let Some(shredded) = variant.shredded() else { + return Ok(core_storage); + }; + + ParquetVariant::try_new( + ParquetVariantArrayExt::validity(&parquet_core), + parquet_core.metadata_array().clone(), + parquet_core.value_array().cloned(), + Some(shredded.clone()), + ) + .map(IntoArray::into_array) +} + impl ArrowExportVTable for ParquetVariant { fn arrow_ext_id(&self) -> Id { *ARROW_PARQUET_VARIANT @@ -148,10 +179,8 @@ impl ArrowExportVTable for ParquetVariant { return Ok(ArrowExport::Unsupported(array)); } - let executed = array.execute_until::(ctx)?; - let parquet_array = executed - .as_opt::() - .ok_or_else(|| vortex_err!("cannot export Variant without ParquetVariant storage"))?; + let parquet_array = parquet_variant_for_export(array, ctx)?; + let parquet_array = parquet_array.as_::(); if let DataType::Struct(fields) = target.data_type() && let Some((request_has_value, request_has_typed_value)) = @@ -261,6 +290,7 @@ mod tests { use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::VarBinViewArray; + use vortex_array::arrays::VariantArray; use vortex_array::arrow::ArrowSessionExt; use vortex_array::assert_arrays_eq; use vortex_array::dtype::DType; @@ -359,6 +389,77 @@ mod tests { Ok(()) } + #[rstest] + fn export_canonical_variant_with_parquet_variant_core_storage( + session: VortexSession, + ) -> VortexResult<()> { + let storage = arrow_variant_storage(); + let field = arrow_variant_field(&storage); + let core_storage = session + .arrow() + .from_arrow_array(Arc::new(storage.clone()) as ArrowArrayRef, &field)?; + let canonical = VariantArray::try_new(core_storage, None)?.into_array(); + + let mut ctx = session.create_execution_ctx(); + let exported = session + .arrow() + .execute_arrow(canonical, Some(&field), &mut ctx)?; + let exported = exported.as_struct(); + + assert_struct_arrays_eq(exported, &storage); + Ok(()) + } + + #[rstest] + fn export_canonical_variant_reattaches_shredded_child( + session: VortexSession, + ) -> VortexResult<()> { + let rows = [ + VariantBuilder::new().with_value(10i32).finish(), + VariantBuilder::new().with_value(20i32).finish(), + VariantBuilder::new().with_value(30i32).finish(), + ]; + let metadata = + VarBinViewArray::from_iter_bin(rows.iter().map(|(metadata, _)| metadata.as_slice())) + .into_array(); + let typed_value = buffer![10i32, 20, 30].into_array(); + let expected = + ParquetVariant::try_new(Validity::NonNullable, metadata, None, Some(typed_value))? + .into_array(); + + let mut ctx = session.create_execution_ctx(); + let canonical = expected + .clone() + .execute::(&mut ctx)? + .into_array(); + let field = Field::new( + "variant", + DataType::Struct( + vec![ + Field::new("metadata", DataType::BinaryView, false), + Field::new("value", DataType::BinaryView, false), + ] + .into(), + ), + false, + ) + .with_metadata( + [( + EXTENSION_TYPE_NAME_KEY.to_string(), + PARQUET_VARIANT_ARROW_EXTENSION_NAME.to_string(), + )] + .into(), + ); + + let exported = session + .arrow() + .execute_arrow(canonical, Some(&field), &mut ctx)?; + assert_eq!(exported.data_type(), field.data_type()); + + let actual = session.arrow().from_arrow_array(exported, &field)?; + assert_variant_scalars_eq(&actual, &expected, &session) + } + #[rstest] fn roundtrip_parquet_variant_extension_array_from_vortex( session: VortexSession, diff --git a/encodings/parquet-variant/src/operations.rs b/encodings/parquet-variant/src/operations.rs index 897c7fca3bb..10f11e2bf2c 100644 --- a/encodings/parquet-variant/src/operations.rs +++ b/encodings/parquet-variant/src/operations.rs @@ -41,7 +41,7 @@ impl OperationsVTable for ParquetVariant { index: usize, ctx: &mut ExecutionCtx, ) -> VortexResult { - if array.validity()?.is_null(index)? { + if array.validity()?.execute_is_null(index, ctx)? { return Ok(Scalar::null(DType::Variant(Nullability::Nullable))); } diff --git a/encodings/parquet-variant/src/vtable.rs b/encodings/parquet-variant/src/vtable.rs index 40f8dbb0604..f2ad4f64743 100644 --- a/encodings/parquet-variant/src/vtable.rs +++ b/encodings/parquet-variant/src/vtable.rs @@ -308,9 +308,9 @@ mod tests { use vortex_array::ArrayEq; use vortex_array::ArrayRef; use vortex_array::Canonical; + use vortex_array::EqMode; use vortex_array::IntoArray; use vortex_array::LEGACY_SESSION; - use vortex_array::Precision; use vortex_array::VTable; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; @@ -525,7 +525,7 @@ mod tests { let array = outer_pv.into_array(); let decoded = roundtrip(array.clone())?; - assert!(array.array_eq(&decoded, Precision::Value)); + assert!(array.array_eq(&decoded, EqMode::Value)); let decoded_pv = decoded .as_opt::() .ok_or_else(|| vortex_err!("expected parquet variant array"))?; @@ -547,7 +547,7 @@ mod tests { let array = pv.into_array(); let decoded = roundtrip(array.clone())?; - assert!(array.array_eq(&decoded, Precision::Value)); + assert!(array.array_eq(&decoded, EqMode::Value)); assert_eq!(decoded.dtype(), &DType::Variant(Nullability::Nullable)); let decoded_pv = decoded .as_opt::() @@ -572,7 +572,7 @@ mod tests { let array = outer_pv.into_array(); let decoded = roundtrip(array.clone())?; - assert!(array.array_eq(&decoded, Precision::Value)); + assert!(array.array_eq(&decoded, EqMode::Value)); let decoded_pv = decoded .as_opt::() .ok_or_else(|| vortex_err!("expected parquet variant array"))?; diff --git a/encodings/pco/src/array.rs b/encodings/pco/src/array.rs index 94db1014c79..df2d79f63f1 100644 --- a/encodings/pco/src/array.rs +++ b/encodings/pco/src/array.rs @@ -25,10 +25,10 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::arrays::Primitive; use vortex_array::arrays::PrimitiveArray; use vortex_array::buffer::BufferHandle; @@ -83,22 +83,22 @@ const VALUES_PER_CHUNK: usize = pco::DEFAULT_MAX_PAGE_N; pub type PcoArray = Array; impl ArrayHash for PcoData { - fn array_hash(&self, state: &mut H, precision: Precision) { + fn array_hash(&self, state: &mut H, accuracy: EqMode) { self.unsliced_n_rows.hash(state); self.slice_start.hash(state); self.slice_stop.hash(state); // Hash chunk_metas and pages using pointer-based hashing for chunk_meta in &self.chunk_metas { - chunk_meta.array_hash(state, precision); + chunk_meta.array_hash(state, accuracy); } for page in &self.pages { - page.array_hash(state, precision); + page.array_hash(state, accuracy); } } } impl ArrayEq for PcoData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { if self.unsliced_n_rows != other.unsliced_n_rows || self.slice_start != other.slice_start || self.slice_stop != other.slice_stop @@ -108,12 +108,12 @@ impl ArrayEq for PcoData { return false; } for (a, b) in self.chunk_metas.iter().zip(&other.chunk_metas) { - if !a.array_eq(b, precision) { + if !a.array_eq(b, accuracy) { return false; } } for (a, b) in self.pages.iter().zip(&other.pages) { - if !a.array_eq(b, precision) { + if !a.array_eq(b, accuracy) { return false; } } diff --git a/encodings/pco/src/tests.rs b/encodings/pco/src/tests.rs index 6e5b4f9841b..20fec7dd9e0 100644 --- a/encodings/pco/src/tests.rs +++ b/encodings/pco/src/tests.rs @@ -149,6 +149,7 @@ fn test_validity_and_multiple_chunks_and_pages() { .unwrap() .mask_eq( &Validity::Array(BoolArray::from_iter(vec![true, false, true]).into_array()), + primitive.len(), &mut ctx, ) .unwrap() diff --git a/encodings/runend/benches/run_end_compress.rs b/encodings/runend/benches/run_end_compress.rs index 1c183f3e5fd..a49a5cfcb50 100644 --- a/encodings/runend/benches/run_end_compress.rs +++ b/encodings/runend/benches/run_end_compress.rs @@ -3,24 +3,30 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use itertools::repeat_n; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::VarBinViewArray; use vortex_array::dtype::IntegerPType; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_runend::RunEnd; use vortex_runend::compress::runend_encode; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + const BENCH_ARGS: &[(usize, usize)] = &[ (1000, 4), (1000, 16), @@ -47,7 +53,7 @@ fn compress(bencher: Bencher, (length, run_step): (usize, usize)) { ); bencher - .with_inputs(|| (&values, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&values, SESSION.create_execution_ctx())) .bench_refs(|(values, ctx)| runend_encode(values.as_view(), ctx)); } @@ -64,11 +70,11 @@ fn decompress(bencher: Bencher, (length, run_step): (usize, usi .collect::>() .into_array(); - let run_end_array = RunEnd::new(ends, values, &mut LEGACY_SESSION.create_execution_ctx()); + let run_end_array = RunEnd::new(ends, values, &mut SESSION.create_execution_ctx()); let array = run_end_array.into_array(); bencher - .with_inputs(|| (array.clone(), LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (array.clone(), SESSION.create_execution_ctx())) .bench_values(|(array, mut execution_ctx)| { array .execute::(&mut execution_ctx) @@ -88,20 +94,14 @@ fn take_indices(bencher: Bencher, (length, run_step): (usize, usize)) { ); let source_array = PrimitiveArray::from_iter(0..(length as i32)).into_array(); - let mut encode_ctx = LEGACY_SESSION.create_execution_ctx(); + let mut encode_ctx = SESSION.create_execution_ctx(); let (ends, values) = runend_encode(values.as_view(), &mut encode_ctx); let runend_array = RunEnd::try_new(ends.into_array(), values, &mut encode_ctx) .unwrap() .into_array(); bencher - .with_inputs(|| { - ( - &source_array, - &runend_array, - LEGACY_SESSION.create_execution_ctx(), - ) - }) + .with_inputs(|| (&source_array, &runend_array, SESSION.create_execution_ctx())) .bench_refs(|(array, indices, execution_ctx)| { array .take(indices.clone()) @@ -122,11 +122,11 @@ fn decompress_utf8(bencher: Bencher, (length, run_step): (usize, usize)) { let values = VarBinViewArray::from_iter_str((0..num_runs).map(|i| format!("run_value_{i}"))) .into_array(); - let run_end_array = RunEnd::new(ends, values, &mut LEGACY_SESSION.create_execution_ctx()); + let run_end_array = RunEnd::new(ends, values, &mut SESSION.create_execution_ctx()); let array = run_end_array.into_array(); bencher - .with_inputs(|| (array.clone(), LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (array.clone(), SESSION.create_execution_ctx())) .bench_values(|(array, mut execution_ctx)| { array .execute::(&mut execution_ctx) diff --git a/encodings/runend/benches/run_end_decode.rs b/encodings/runend/benches/run_end_decode.rs index 818c8eb4ed6..f509e55aed3 100644 --- a/encodings/runend/benches/run_end_decode.rs +++ b/encodings/runend/benches/run_end_decode.rs @@ -4,21 +4,26 @@ #![expect(clippy::cast_possible_truncation)] use std::fmt; +use std::sync::LazyLock; use divan::Bencher; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; use vortex_array::arrays::PrimitiveArray; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::BitBuffer; use vortex_buffer::BufferMut; use vortex_runend::decompress_bool::runend_decode_bools; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + /// Distribution types for bool benchmarks #[derive(Clone, Copy)] enum BoolDistribution { @@ -210,13 +215,7 @@ fn decode_bool(bencher: Bencher, args: BoolBenchArgs) { } = args; let (ends, values) = create_bool_test_data(total_length, avg_run_length, distribution); bencher - .with_inputs(|| { - ( - ends.clone(), - values.clone(), - LEGACY_SESSION.create_execution_ctx(), - ) - }) + .with_inputs(|| (ends.clone(), values.clone(), SESSION.create_execution_ctx())) .bench_refs(|(ends, values, ctx)| { runend_decode_bools(ends.clone(), values.clone(), 0, total_length, ctx) }); @@ -379,13 +378,7 @@ fn decode_bool_nullable(bencher: Bencher, args: NullableBoolBenchArgs) { let (ends, values) = create_nullable_bool_test_data(total_length, avg_run_length, distribution, validity); bencher - .with_inputs(|| { - ( - ends.clone(), - values.clone(), - LEGACY_SESSION.create_execution_ctx(), - ) - }) + .with_inputs(|| (ends.clone(), values.clone(), SESSION.create_execution_ctx())) .bench_refs(|(ends, values, ctx)| { runend_decode_bools(ends.clone(), values.clone(), 0, total_length, ctx) }); diff --git a/encodings/runend/benches/run_end_null_count.rs b/encodings/runend/benches/run_end_null_count.rs index c60c72c5e0e..5392e04189b 100644 --- a/encodings/runend/benches/run_end_null_count.rs +++ b/encodings/runend/benches/run_end_null_count.rs @@ -3,17 +3,20 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; +use vortex_array::session::ArraySession; use vortex_buffer::Buffer; use vortex_runend::RunEnd; use vortex_runend::RunEndArray; +use vortex_session::VortexSession; fn main() { divan::main(); @@ -47,15 +50,16 @@ const BENCH_ARGS: &[(usize, usize, f64)] = &[ (100_000, 1024, 0.5), ]; +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench(args = BENCH_ARGS)] fn null_count_run_end(bencher: Bencher, (n, run_step, valid_density): (usize, usize, f64)) { let array = fixture(n, run_step, valid_density).into_array(); - bencher.with_inputs(|| &array).bench_refs(|array| { - array - .invalid_count(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_refs(|(array, ctx)| array.invalid_count(ctx).unwrap()); } fn fixture(n: usize, run_step: usize, valid_density: f64) -> RunEndArray { @@ -72,5 +76,5 @@ fn fixture(n: usize, run_step: usize, valid_density: f64) -> RunEndArray { ) .into_array(); - RunEnd::new(ends, values, &mut LEGACY_SESSION.create_execution_ctx()) + RunEnd::new(ends, values, &mut SESSION.create_execution_ctx()) } diff --git a/encodings/runend/src/array.rs b/encodings/runend/src/array.rs index 943f4881806..7bb069bb458 100644 --- a/encodings/runend/src/array.rs +++ b/encodings/runend/src/array.rs @@ -15,11 +15,11 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; use vortex_array::LEGACY_SESSION; -use vortex_array::Precision; use vortex_array::TypedArrayRef; use vortex_array::VortexSessionExecute; use vortex_array::arrays::Primitive; @@ -65,13 +65,13 @@ pub struct RunEndMetadata { } impl ArrayHash for RunEndData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.offset.hash(state); } } impl ArrayEq for RunEndData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.offset == other.offset } } diff --git a/encodings/runend/src/rules.rs b/encodings/runend/src/rules.rs index cd80e42db46..ac7d6038146 100644 --- a/encodings/runend/src/rules.rs +++ b/encodings/runend/src/rules.rs @@ -77,8 +77,7 @@ impl ArrayParentReduceRule for RunEndScalarFnRule { } let new_values = - ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children, values_len)? - .into_array(); + ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children)?.into_array(); Ok(Some( unsafe { diff --git a/encodings/sequence/src/array.rs b/encodings/sequence/src/array.rs index a572a5fc165..af8b146cfba 100644 --- a/encodings/sequence/src/array.rs +++ b/encodings/sequence/src/array.rs @@ -16,9 +16,9 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; -use vortex_array::Precision; use vortex_array::buffer::BufferHandle; use vortex_array::dtype::DType; use vortex_array::dtype::NativePType; @@ -214,14 +214,14 @@ impl SequenceData { } impl ArrayHash for SequenceData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.base.hash(state); self.multiplier.hash(state); } } impl ArrayEq for SequenceData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.base == other.base && self.multiplier == other.multiplier } } diff --git a/encodings/sparse/benches/sparse_canonical.rs b/encodings/sparse/benches/sparse_canonical.rs index ba1489a0e5a..3861e26da06 100644 --- a/encodings/sparse/benches/sparse_canonical.rs +++ b/encodings/sparse/benches/sparse_canonical.rs @@ -4,12 +4,12 @@ #![expect(clippy::cast_possible_truncation)] use std::sync::Arc; +use std::sync::LazyLock; use divan::Bencher; use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::FixedSizeListArray; use vortex_array::arrays::ListViewArray; @@ -17,15 +17,20 @@ use vortex_array::arrays::PrimitiveArray; use vortex_array::dtype::Nullability::NonNullable; use vortex_array::dtype::PType::I32; use vortex_array::scalar::Scalar; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_error::VortexExpect; +use vortex_session::VortexSession; use vortex_sparse::Sparse; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + const LIST_ARGS: &[(usize, usize, usize)] = &[ // len, patch_stride, list_size (512, 7, 4), @@ -93,7 +98,7 @@ fn canonicalize_sparse_list( let sparse = make_sparse_list(len, patch_stride, list_size); bencher - .with_inputs(|| (sparse.clone(), LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (sparse.clone(), SESSION.create_execution_ctx())) .bench_values(|(array, mut ctx)| { divan::black_box( array @@ -111,7 +116,7 @@ fn canonicalize_sparse_fixed_size_list( let sparse = make_sparse_fixed_size_list(len, patch_stride, list_size); bencher - .with_inputs(|| (sparse.clone(), LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (sparse.clone(), SESSION.create_execution_ctx())) .bench_values(|(array, mut ctx)| { divan::black_box( array diff --git a/encodings/sparse/src/canonical.rs b/encodings/sparse/src/canonical.rs index ac8c072e7b1..dd84123851a 100644 --- a/encodings/sparse/src/canonical.rs +++ b/encodings/sparse/src/canonical.rs @@ -226,7 +226,11 @@ fn execute_sparse_lists_inner( let mut next_index = 0; - for (patch_idx, sparse_idx) in patch_indices.iter().enumerate() { + for ((patch_idx, sparse_idx), patch_valid) in patch_indices + .iter() + .enumerate() + .zip(patch_values_validity.iter()) + { let sparse_idx = sparse_idx .to_usize() .vortex_expect("patch index must fit in usize"); @@ -237,7 +241,7 @@ fn execute_sparse_lists_inner( sparse_idx - next_index, ); - if patch_values_validity.value(patch_idx) { + if patch_valid { let patch_list = patch_values .list_elements_at(patch_idx) .vortex_expect("list_elements_at"); @@ -318,7 +322,7 @@ fn execute_sparse_fixed_size_list_inner( .iter() .map(|x| (*x).to_usize().vortex_expect("index must fit in usize")); - for (patch_idx, sparse_idx) in indices.enumerate() { + for ((patch_idx, sparse_idx), patch_valid) in indices.enumerate().zip(values_validity.iter()) { // Fill gap before this patch with fill values. append_fixed_size_list_fill( &mut builder, @@ -327,7 +331,7 @@ fn execute_sparse_fixed_size_list_inner( ); // Append the patch value, handling null patches by appending defaults. - if values_validity.value(patch_idx) { + if patch_valid { let patch_list = values .fixed_size_list_elements_at(patch_idx) .vortex_expect("fixed_size_list_elements_at"); diff --git a/encodings/sparse/src/lib.rs b/encodings/sparse/src/lib.rs index 42b5cd46724..e74cedb942e 100644 --- a/encodings/sparse/src/lib.rs +++ b/encodings/sparse/src/lib.rs @@ -19,10 +19,10 @@ use vortex_array::ArrayRef; use vortex_array::ArraySlots; use vortex_array::ArrayView; use vortex_array::Canonical; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::Primitive; @@ -165,7 +165,7 @@ pub struct SparseMetadata { } impl ArrayHash for SparseData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.array_len.hash(state); self.patches_data.hash(state); self.fill_value.hash(state); @@ -173,7 +173,7 @@ impl ArrayHash for SparseData { } impl ArrayEq for SparseData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.array_len == other.array_len && self.patches_data == other.patches_data && self.fill_value == other.fill_value diff --git a/encodings/zigzag/src/array.rs b/encodings/zigzag/src/array.rs index 26d3bf984e2..f03315ae575 100644 --- a/encodings/zigzag/src/array.rs +++ b/encodings/zigzag/src/array.rs @@ -12,10 +12,10 @@ use vortex_array::ArrayId; use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::TypedArrayRef; use vortex_array::buffer::BufferHandle; use vortex_array::dtype::DType; @@ -155,11 +155,11 @@ impl VTable for ZigZag { } impl ArrayHash for ZigZagData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl ArrayEq for ZigZagData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } diff --git a/encodings/zstd/benches/listview_rebuild.rs b/encodings/zstd/benches/listview_rebuild.rs index 4b8ca95eafc..fe4e9f063d1 100644 --- a/encodings/zstd/benches/listview_rebuild.rs +++ b/encodings/zstd/benches/listview_rebuild.rs @@ -3,18 +3,25 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ListViewArray; use vortex_array::arrays::VarBinViewArray; use vortex_array::arrays::listview::ListViewRebuildMode; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; +use vortex_session::VortexSession; use vortex_zstd::Zstd; use vortex_zstd::ZstdData; +/// A shared session for the `ListView` rebuild benchmark, used to create execution contexts. +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench(sample_size = 1000)] fn rebuild_naive(bencher: Bencher) { let dudes = VarBinViewArray::from_iter_str(["Washington", "Adams", "Jefferson", "Madison"]) @@ -23,7 +30,7 @@ fn rebuild_naive(bencher: Bencher) { let validity = dudes.validity().unwrap(); let dudes = Zstd::try_new( dtype, - ZstdData::from_array(dudes, 9, 1024, &mut LEGACY_SESSION.create_execution_ctx()).unwrap(), + ZstdData::from_array(dudes, 9, 1024, &mut SESSION.create_execution_ctx()).unwrap(), validity, ) .unwrap() @@ -42,8 +49,10 @@ fn rebuild_naive(bencher: Bencher) { let list_view = ListViewArray::new(dudes, offsets, sizes, Validity::NonNullable); bencher - .with_inputs(|| &list_view) - .bench_refs(|list_view| list_view.rebuild(ListViewRebuildMode::MakeZeroCopyToList)) + .with_inputs(|| (&list_view, SESSION.create_execution_ctx())) + .bench_refs(|(list_view, ctx)| { + list_view.rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + }) } fn main() { diff --git a/encodings/zstd/src/array.rs b/encodings/zstd/src/array.rs index b327a6a2a95..0e3bccc4667 100644 --- a/encodings/zstd/src/array.rs +++ b/encodings/zstd/src/array.rs @@ -18,10 +18,10 @@ use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArrayView; use vortex_array::Canonical; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::Precision; use vortex_array::accessor::ArrayAccessor; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::PrimitiveArray; @@ -84,18 +84,18 @@ type ViewLen = u32; pub type ZstdArray = Array; impl ArrayHash for ZstdData { - fn array_hash(&self, state: &mut H, precision: Precision) { + fn array_hash(&self, state: &mut H, accuracy: EqMode) { match &self.dictionary { Some(dict) => { true.hash(state); - dict.array_hash(state, precision); + dict.array_hash(state, accuracy); } None => { false.hash(state); } } for frame in &self.frames { - frame.array_hash(state, precision); + frame.array_hash(state, accuracy); } self.unsliced_n_rows.hash(state); self.slice_start.hash(state); @@ -104,9 +104,9 @@ impl ArrayHash for ZstdData { } impl ArrayEq for ZstdData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { if !match (&self.dictionary, &other.dictionary) { - (Some(d1), Some(d2)) => d1.array_eq(d2, precision), + (Some(d1), Some(d2)) => d1.array_eq(d2, accuracy), (None, None) => true, _ => false, } { @@ -116,7 +116,7 @@ impl ArrayEq for ZstdData { return false; } for (a, b) in self.frames.iter().zip(&other.frames) { - if !a.array_eq(b, precision) { + if !a.array_eq(b, accuracy) { return false; } } @@ -354,7 +354,7 @@ pub struct ZstdDataParts { pub slice_stop: usize, } -/// The parts of a [`ZstdArray`] returned by [`ZstdArray::into_parts`]. +/// Compressed ZStd frames and their metadata #[derive(Debug)] struct Frames { dictionary: Option, diff --git a/encodings/zstd/src/compute/cast.rs b/encodings/zstd/src/compute/cast.rs index e295e556566..6cbe4f37795 100644 --- a/encodings/zstd/src/compute/cast.rs +++ b/encodings/zstd/src/compute/cast.rs @@ -41,7 +41,7 @@ impl CastReduce for Zstd { child_to_validity(array.slots()[0].as_ref(), array.dtype().nullability()); let has_nulls = !unsliced_validity .slice(array.slice_start()..array.slice_stop())? - .no_nulls(); + .definitely_no_nulls(); // We don't attempt to handle casting when there are nulls. if has_nulls { diff --git a/encodings/zstd/src/test.rs b/encodings/zstd/src/test.rs index 7ed22886b82..094c5ef60cc 100644 --- a/encodings/zstd/src/test.rs +++ b/encodings/zstd/src/test.rs @@ -89,7 +89,7 @@ fn test_zstd_with_validity_and_multi_frame() { decompressed .validity() .unwrap() - .mask_eq(&array.validity().unwrap(), &mut ctx) + .mask_eq(&array.validity().unwrap(), decompressed.len(), &mut ctx) .unwrap() ); @@ -106,6 +106,7 @@ fn test_zstd_with_validity_and_multi_frame() { .unwrap() .mask_eq( &Validity::Array(BoolArray::from_iter(vec![false, true, false]).into_array()), + primitive.len(), &mut ctx ) .unwrap() diff --git a/encodings/zstd/src/zstd_buffers.rs b/encodings/zstd/src/zstd_buffers.rs index 597817456f2..dd689fd76bd 100644 --- a/encodings/zstd/src/zstd_buffers.rs +++ b/encodings/zstd/src/zstd_buffers.rs @@ -17,9 +17,9 @@ use vortex_array::ArrayParts; use vortex_array::ArrayRef; use vortex_array::ArraySlots; use vortex_array::ArrayView; +use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; -use vortex_array::Precision; use vortex_array::buffer::BufferHandle; use vortex_array::dtype::DType; use vortex_array::scalar::Scalar; @@ -337,11 +337,11 @@ fn array_id_from_string(s: &str) -> ArrayId { } impl ArrayHash for ZstdBuffersData { - fn array_hash(&self, state: &mut H, precision: Precision) { + fn array_hash(&self, state: &mut H, accuracy: EqMode) { self.inner_encoding_id.hash(state); self.inner_metadata.hash(state); for buf in &self.compressed_buffers { - buf.array_hash(state, precision); + buf.array_hash(state, accuracy); } self.uncompressed_sizes.hash(state); self.buffer_alignments.hash(state); @@ -349,7 +349,7 @@ impl ArrayHash for ZstdBuffersData { } impl ArrayEq for ZstdBuffersData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { self.inner_encoding_id == other.inner_encoding_id && self.inner_metadata == other.inner_metadata && self.compressed_buffers.len() == other.compressed_buffers.len() @@ -357,7 +357,7 @@ impl ArrayEq for ZstdBuffersData { .compressed_buffers .iter() .zip(&other.compressed_buffers) - .all(|(a, b)| a.array_eq(b, precision)) + .all(|(a, b)| a.array_eq(b, accuracy)) && self.uncompressed_sizes == other.uncompressed_sizes && self.buffer_alignments == other.buffer_alignments } diff --git a/java/build.gradle.kts b/java/build.gradle.kts index 73ea3108418..43cc24b48ed 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -61,7 +61,7 @@ allprojects { } tasks.withType { - (options as StandardJavadocDocletOptions).addStringOption("Xdoclint:-missing") + (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:-missing", true) } the().toolchain { diff --git a/java/gradle/libs.versions.toml b/java/gradle/libs.versions.toml index 464f6339603..1135946d4ab 100644 --- a/java/gradle/libs.versions.toml +++ b/java/gradle/libs.versions.toml @@ -7,9 +7,10 @@ errorprone = "2.36.0" guava = "33.6.0-jre" immutables = "2.12.2" junit-jupiter = "6.1.0" -logback = "1.5.33" -netty = "4.2.14.Final" +logback = "1.5.34" +netty = "4.2.15.Final" nopen = "1.0.1" +roaringbitmap = "1.6.14" slf4j = "2.0.18" spark3 = "3.5.8" spark4 = "4.1.1" @@ -30,7 +31,8 @@ logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "lo netty-bom = { module = "io.netty:netty-bom", version.ref = "netty" } nopen-annotations = { module = "com.jakewharton.nopen:nopen-annotations", version.ref = "nopen" } nopen-checker = { module = "com.jakewharton.nopen:nopen-checker", version.ref = "nopen" } +roaringbitmap = { module = "org.roaringbitmap:RoaringBitmap", version.ref = "roaringbitmap" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } s3mock-testcontainers = { module = "com.adobe.testing:s3mock-testcontainers", version.ref = "s3mock" } -testcontainers-juputer = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers-jupiter" } \ No newline at end of file +testcontainers-juputer = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers-jupiter" } diff --git a/java/vortex-jni/build.gradle.kts b/java/vortex-jni/build.gradle.kts index 3ffc0f8ef6c..bb652d0d86e 100644 --- a/java/vortex-jni/build.gradle.kts +++ b/java/vortex-jni/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(libs.guava) compileOnly(libs.errorprone.annotations) compileOnly(libs.nopen.annotations) + api(libs.roaringbitmap) // Logging implementation(libs.slf4j.api) @@ -48,7 +49,9 @@ mavenPublishing { coordinates(groupId = "dev.vortex", artifactId = "vortex-jni", version = "${rootProject.version}") publishToMavenCentral() - signAllPublications() + if (!project.hasProperty("skip.signing")) { + signAllPublications() + } pom { name = "vortex-jni" @@ -131,18 +134,20 @@ tasks.register("makeTestFiles") { val osName = System.getProperty("os.name").lowercase() val osArch = System.getProperty("os.arch").lowercase() - val osShortName = when { - osName.contains("mac") -> "darwin" - osName.contains("nix") || osName.contains("nux") -> "linux" - osName.contains("win") -> "win" - else -> throw GradleException("Unsupported OS for makeTestFiles: $osName") - } - val libExt = when (osShortName) { - "darwin" -> ".dylib" - "linux" -> ".so" - "win" -> ".dll" - else -> throw GradleException("Unsupported OS short name: $osShortName") - } + val osShortName = + when { + osName.contains("mac") -> "darwin" + osName.contains("nix") || osName.contains("nux") -> "linux" + osName.contains("win") -> "win" + else -> throw GradleException("Unsupported OS for makeTestFiles: $osName") + } + val libExt = + when (osShortName) { + "darwin" -> ".dylib" + "linux" -> ".so" + "win" -> ".dll" + else -> throw GradleException("Unsupported OS short name: $osShortName") + } // Only populate the host-arch directory so cross-compiled libs for other // architectures (placed by the publish workflow) are preserved. diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java b/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java index d13f7e6acc1..24780785274 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java +++ b/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java @@ -136,13 +136,40 @@ public Scan scan(ScanOptions options) { long filterPtr = options.filter().map(Expression::nativePointer).orElse(0L); long begin = options.rowRangeBegin().orElse(0L); long end = options.rowRangeEnd().orElse(0L); - long[] selectionIndices = options.selectionIndices().orElse(null); - byte selectionMode = options.selectionMode().code(); + ScanOptions.SelectionMode selectionMode = options.selectionMode(); + long[] selectionIndices = selectionIndices(options); + byte[] selectionRoaringBitmap = selectionRoaringBitmap(options); long limit = options.limit().orElse(0L); boolean ordered = options.ordered(); long scanPtr = dev.vortex.jni.NativeScan.create( - pointer, projectionPtr, filterPtr, begin, end, selectionIndices, selectionMode, limit, ordered); + pointer, + projectionPtr, + filterPtr, + begin, + end, + selectionIndices, + selectionRoaringBitmap, + selectionMode.code(), + limit, + ordered); return Scan.fromPointer(session, scanPtr); } + + private static long[] selectionIndices(ScanOptions options) { + return switch (options.selectionMode()) { + case INCLUDE, EXCLUDE -> options.selectionIndices().orElse(null); + default -> null; + }; + } + + private static byte[] selectionRoaringBitmap(ScanOptions options) { + return switch (options.selectionMode()) { + case INCLUDE_ROARING, EXCLUDE_ROARING -> + options.selectionRoaringBitmap() + .orElseThrow(() -> new IllegalArgumentException( + "selection roaring bitmap is required for roaring selection modes")); + default -> null; + }; + } } diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/Expression.java b/java/vortex-jni/src/main/java/dev/vortex/api/Expression.java index 922c6ae3f07..b2e2a8be875 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/api/Expression.java +++ b/java/vortex-jni/src/main/java/dev/vortex/api/Expression.java @@ -79,6 +79,30 @@ public static Expression select(String[] fieldNames, Expression child) { return new Expression(NativeExpression.select(fieldNames, child.nativePointer())); } + /** Creates an expression that packs values into a struct with named fields. */ + public static Expression pack(String[] fieldNames, Expression[] expressions, boolean nullable) { + return new Expression(NativeExpression.pack(fieldNames, nativePointers(expressions), nullable)); + } + + /** + * Merge struct expressions into a single struct, combining their fields in order. + * + *

Every input must evaluate to a non-nullable struct. When the same field name appears in more than one input, + * {@code duplicateHandling} decides the result. Fields are not merged recursively — a later field replaces + * an earlier one with the same name. Merging zero expressions yields an empty struct. + */ + public static Expression merge(DuplicateHandling duplicateHandling, Expression... expressions) { + return new Expression(NativeExpression.merge(nativePointers(expressions), duplicateHandling.tag())); + } + + /** + * Merge struct expressions, failing if any field name is duplicated. Equivalent to {@link #merge(DuplicateHandling, + * Expression...)} with {@link DuplicateHandling#ERROR}. + */ + public static Expression merge(Expression... expressions) { + return merge(DuplicateHandling.ERROR, expressions); + } + /** Logical AND. Requires at least one operand. */ public static Expression and(Expression... operands) { Preconditions.checkArgument(operands.length > 0, "and requires at least one operand"); @@ -287,6 +311,27 @@ public byte code() { } } + /** + * Strategy for resolving duplicate field names in {@link #merge(DuplicateHandling, Expression...)}. Tag values must + * match the Rust {@code parse_duplicate_handling} table. + */ + public enum DuplicateHandling { + /** When two structs share a field name, keep the value from the right-most (later) struct. */ + RIGHT_MOST((byte) 0), + /** When two structs share a field name, fail with an error. */ + ERROR((byte) 1); + + private final byte tag; + + DuplicateHandling(byte tag) { + this.tag = tag; + } + + public byte tag() { + return tag; + } + } + /** Time units for Date/Timestamp literals. Tag values must match the Rust {@code parse_time_unit} table. */ public enum TimeUnit { NANOSECONDS((byte) 0), diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/ScanOptions.java b/java/vortex-jni/src/main/java/dev/vortex/api/ScanOptions.java index 2431f9e5722..e9009582e28 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/api/ScanOptions.java +++ b/java/vortex-jni/src/main/java/dev/vortex/api/ScanOptions.java @@ -3,9 +3,15 @@ package dev.vortex.api; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; import java.util.Optional; import java.util.OptionalLong; import org.immutables.value.Value; +import org.roaringbitmap.longlong.Roaring64NavigableMap; /** * Scan configuration passed to {@link DataSource#scan(ScanOptions)}. @@ -28,12 +34,15 @@ public interface ScanOptions { OptionalLong rowRangeEnd(); /** - * Sorted ascending row indices that should be included in (or excluded from) the scan, depending on + * Sorted ascending, unique row indices that should be included in (or excluded from) the scan, depending on * {@link #selectionMode()}. */ Optional selectionIndices(); - /** Meaning of {@link #selectionIndices()}. */ + /** Portable serialized {@link Roaring64NavigableMap} row selection. */ + Optional selectionRoaringBitmap(); + + /** Meaning of the row selection payload. */ @Value.Default default SelectionMode selectionMode() { return SelectionMode.INCLUDE_ALL; @@ -48,6 +57,54 @@ default boolean ordered() { return false; } + @Value.Check + default void validateSelectionPayload() { + boolean hasIndices = selectionIndices().isPresent(); + boolean hasRoaringBitmap = selectionRoaringBitmap().isPresent(); + if (hasIndices && hasRoaringBitmap) { + throw new IllegalArgumentException("row selection must use either indices or roaring bitmap, not both"); + } + if (hasIndices) { + validateSelectionIndices(selectionIndices().orElseThrow()); + } + + switch (selectionMode()) { + case INCLUDE_ALL -> { + if (hasIndices || hasRoaringBitmap) { + throw new IllegalArgumentException("row selection payload requires a selection mode"); + } + } + case INCLUDE, EXCLUDE -> { + if (!hasIndices) { + throw new IllegalArgumentException("selection indices are required for index selection modes"); + } + } + case INCLUDE_ROARING, EXCLUDE_ROARING -> { + if (!hasRoaringBitmap) { + throw new IllegalArgumentException( + "selection roaring bitmap is required for roaring selection modes"); + } + if (selectionRoaringBitmap().orElseThrow().length == 0) { + throw new IllegalArgumentException("selection roaring bitmap must not be empty"); + } + } + } + } + + private static void validateSelectionIndices(long[] selectionIndices) { + long previous = -1L; + for (int i = 0; i < selectionIndices.length; i++) { + long index = selectionIndices[i]; + if (index < 0) { + throw new IllegalArgumentException("selection indices must be non-negative"); + } + if (i > 0 && index <= previous) { + throw new IllegalArgumentException("selection indices must be sorted ascending and unique"); + } + previous = index; + } + } + static ScanOptions of() { return ImmutableScanOptions.builder().build(); } @@ -56,14 +113,62 @@ static ImmutableScanOptions.Builder builder() { return ImmutableScanOptions.builder(); } - /** How to interpret {@link #selectionIndices()}. */ + /** Scan only the rows at the given sorted ascending, unique row indices. */ + static ScanOptions includeRows(long... rowIndices) { + return builder() + .selectionIndices(rowIndices.clone()) + .selectionMode(SelectionMode.INCLUDE) + .build(); + } + + /** Scan all rows except the given sorted ascending, unique row indices. */ + static ScanOptions excludeRows(long... rowIndices) { + return builder() + .selectionIndices(rowIndices.clone()) + .selectionMode(SelectionMode.EXCLUDE) + .build(); + } + + /** Scan only the rows in the given Roaring bitmap. */ + static ScanOptions includeRows(Roaring64NavigableMap rowSelection) { + return builder() + .selectionRoaringBitmap(serializeRoaringBitmap(rowSelection)) + .selectionMode(SelectionMode.INCLUDE_ROARING) + .build(); + } + + /** Scan all rows except the rows in the given Roaring bitmap. */ + static ScanOptions excludeRows(Roaring64NavigableMap rowSelection) { + return builder() + .selectionRoaringBitmap(serializeRoaringBitmap(rowSelection)) + .selectionMode(SelectionMode.EXCLUDE_ROARING) + .build(); + } + + private static byte[] serializeRoaringBitmap(Roaring64NavigableMap rowSelection) { + Objects.requireNonNull(rowSelection, "rowSelection"); + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + DataOutputStream dataOutput = new DataOutputStream(output)) { + rowSelection.serializePortable(dataOutput); + dataOutput.flush(); + return output.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** How to interpret the row selection payload. */ enum SelectionMode { - /** Ignore {@link #selectionIndices()}. */ + /** Ignore row selection payloads. */ INCLUDE_ALL((byte) 0), /** Return only rows at the indices. */ INCLUDE((byte) 1), /** Return rows except those at the indices. */ - EXCLUDE((byte) 2); + EXCLUDE((byte) 2), + /** Return only rows in the Roaring bitmap. */ + INCLUDE_ROARING((byte) 3), + /** Return rows except those in the Roaring bitmap. */ + EXCLUDE_ROARING((byte) 4); private final byte code; diff --git a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeExpression.java b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeExpression.java index 068927bdc79..bcd82d4b313 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeExpression.java +++ b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeExpression.java @@ -19,6 +19,10 @@ private NativeExpression() {} public static native long select(String[] fieldNames, long childPointer); + public static native long pack(String[] fieldNames, long[] expressions, boolean nullable); + + public static native long merge(long[] expressions, byte duplicateHandling); + public static native long and(long[] operandPointers); public static native long or(long[] operandPointers); diff --git a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeScan.java b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeScan.java index fe1a2e7a674..86b4b2e3359 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeScan.java +++ b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeScan.java @@ -21,8 +21,10 @@ private NativeScan() {} * @param rowRangeBegin inclusive start of the row range, 0 for "unbounded" * @param rowRangeEnd exclusive end of the row range, 0 for "unbounded" * @param selectionIndices sorted row indices; may be null + * @param selectionRoaringBitmap portable serialized Roaring64 bitmap; may be null * @param selectionInclude {@code 0} (all), {@code 1} (include {@code selectionIndices}), {@code 2} (exclude - * {@code selectionIndices}) + * {@code selectionIndices}), {@code 3} (include {@code selectionRoaringBitmap}), {@code 4} (exclude + * {@code selectionRoaringBitmap}) * @param limit max rows to return, or {@code 0} for "no limit" * @param ordered true to preserve row order across partitions */ @@ -33,6 +35,7 @@ public static native long create( long rowRangeBegin, long rowRangeEnd, long[] selectionIndices, + byte[] selectionRoaringBitmap, byte selectionInclude, long limit, boolean ordered); diff --git a/java/vortex-jni/src/test/java/dev/vortex/api/ExpressionTest.java b/java/vortex-jni/src/test/java/dev/vortex/api/ExpressionTest.java index f171267253b..e02024311d8 100644 --- a/java/vortex-jni/src/test/java/dev/vortex/api/ExpressionTest.java +++ b/java/vortex-jni/src/test/java/dev/vortex/api/ExpressionTest.java @@ -22,7 +22,8 @@ public static void loadLibrary() { @Test public void rowIdxBuildsAndComposes() { assertNotNull(Expression.rowIdx()); - // Mirrors `gt(row_idx(), lit(...))` on the Rust side: the row-index expression composes like any other. + // Mirrors `gt(row_idx(), lit(...))` on the Rust side: the row-index expression + // composes like any other. assertNotNull(Expression.binary(Expression.BinaryOp.LT, Expression.rowIdx(), Expression.literal(5L))); } @@ -35,4 +36,23 @@ public void literalDecimalRejectsValuesLargerThan32Bytes() { assertThrows(RuntimeException.class, () -> Expression.literalDecimal(tooLarge, 76, 0)); assertTrue(exception.getMessage().contains("Decimal value must fit with 32 bytes")); } + + @Test + public void packComposes() { + assertNotNull(Expression.pack( + new String[] {"x", "y", "z"}, + new Expression[] {Expression.column("a"), Expression.literal(5L), Expression.rowIdx()}, + true)); + } + + @Test + public void mergeComposes() { + // Default duplicate handling (ERROR). + assertNotNull(Expression.merge(Expression.column("a"), Expression.column("b"))); + // Explicit duplicate handling. + assertNotNull(Expression.merge( + Expression.DuplicateHandling.RIGHT_MOST, Expression.column("a"), Expression.column("b"))); + // Merging zero expressions is valid and yields an empty struct. + assertNotNull(Expression.merge()); + } } diff --git a/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java b/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java index b422b767dd3..322dc5522c3 100644 --- a/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java +++ b/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java @@ -5,6 +5,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import dev.vortex.arrow.ArrowAllocation; import dev.vortex.jni.NativeLoader; @@ -31,6 +32,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.roaringbitmap.longlong.Roaring64NavigableMap; public final class TestMinimal { static final class Person { @@ -180,6 +182,73 @@ public void testProjectedScanWithFilter() throws Exception { assertEquals(List.of(new Person("John", BigDecimal.valueOf(10_000L, 2), "VA")), people); } + @Test + public void testSelectionIncludesRows() throws Exception { + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + DataSource ds = DataSource.open(session, writePath); + + List people = readAll(ds, ScanOptions.includeRows(0, 3, 9), allocator, TestMinimal::readFullBatch); + assertEquals(List.of(MINIMAL_DATA.get(0), MINIMAL_DATA.get(3), MINIMAL_DATA.get(9)), people); + } + + @Test + public void testSelectionExcludesRows() throws Exception { + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + DataSource ds = DataSource.open(session, writePath); + + List people = readAll(ds, ScanOptions.excludeRows(0, 9), allocator, TestMinimal::readFullBatch); + assertEquals(MINIMAL_DATA.subList(1, 9), people); + } + + @Test + public void testRoaringSelectionIncludesRows() throws Exception { + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + DataSource ds = DataSource.open(session, writePath); + + List people = + readAll(ds, ScanOptions.includeRows(roaringRows(0, 3, 9)), allocator, TestMinimal::readFullBatch); + assertEquals(List.of(MINIMAL_DATA.get(0), MINIMAL_DATA.get(3), MINIMAL_DATA.get(9)), people); + } + + @Test + public void testRoaringSelectionExcludesRows() throws Exception { + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + DataSource ds = DataSource.open(session, writePath); + + List people = + readAll(ds, ScanOptions.excludeRows(roaringRows(0, 9)), allocator, TestMinimal::readFullBatch); + assertEquals(MINIMAL_DATA.subList(1, 9), people); + } + + @Test + public void testSelectionIndicesMustBeSortedAndUnique() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> ScanOptions.includeRows(2, 1)); + assertEquals("selection indices must be sorted ascending and unique", exception.getMessage()); + } + + @Test + public void testSelectionPayloadMustChooseIndicesOrRoaring() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> ScanOptions.builder() + .selectionMode(ScanOptions.SelectionMode.INCLUDE) + .selectionIndices(new long[] {0}) + .selectionRoaringBitmap(new byte[] {1}) + .build()); + assertEquals("row selection must use either indices or roaring bitmap, not both", exception.getMessage()); + } + + @Test + public void testSelectionPayloadRequiresMode() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ScanOptions.builder().selectionIndices(new long[] {0}).build()); + assertEquals("row selection payload requires a selection mode", exception.getMessage()); + } + private interface BatchReader { List read(VectorSchemaRoot root); } @@ -221,4 +290,12 @@ private static List readFullBatch(VectorSchemaRoot root) { } return result; } + + private static Roaring64NavigableMap roaringRows(long... rows) { + Roaring64NavigableMap bitmap = new Roaring64NavigableMap(); + for (long row : rows) { + bitmap.addLong(row); + } + return bitmap; + } } diff --git a/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java b/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java index 58cdbbf5315..03869ae8598 100644 --- a/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java +++ b/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java @@ -4,6 +4,7 @@ package dev.vortex.jni; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -26,17 +27,25 @@ import org.apache.arrow.c.Data; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VarBinaryVector; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.complex.StructVector; import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; public final class JNIWriterTest { + private static final String ARROW_EXTENSION_NAME = "ARROW:extension:name"; + private static final String PARQUET_VARIANT_EXTENSION_NAME = "arrow.parquet.variant"; + private static final byte[] VARIANT_METADATA = new byte[] {0x01, 0x00}; + private static final byte[] VARIANT_INT8_42 = new byte[] {0x0c, 0x2a}; + private static final byte[] VARIANT_TRUE = new byte[] {0x04}; @TempDir Path tempDir; @@ -52,6 +61,45 @@ private static Schema personSchema() { Field.notNullable("age", new ArrowType.Int(32, true)))); } + private static Schema parquetVariantSchema() { + Field variant = new Field( + "variant", + new FieldType( + true, + ArrowType.Struct.INSTANCE, + null, + Map.of(ARROW_EXTENSION_NAME, PARQUET_VARIANT_EXTENSION_NAME)), + List.of( + Field.notNullable("metadata", new ArrowType.Binary()), + Field.nullable("value", new ArrowType.Binary()))); + return new Schema(List.of(variant)); + } + + private static void populateParquetVariantRoot(VectorSchemaRoot root) { + StructVector variant = (StructVector) root.getVector("variant"); + VarBinaryVector metadata = variant.getChild("metadata", VarBinaryVector.class); + VarBinaryVector value = variant.getChild("value", VarBinaryVector.class); + + variant.allocateNew(); + metadata.allocateNew(3); + value.allocateNew(3); + + metadata.setSafe(0, VARIANT_METADATA); + metadata.setSafe(1, VARIANT_METADATA); + metadata.setSafe(2, VARIANT_METADATA); + value.setSafe(0, VARIANT_INT8_42); + value.setSafe(1, VARIANT_TRUE); + value.setNull(2); + variant.setIndexDefined(0); + variant.setIndexDefined(1); + variant.setNull(2); + + metadata.setValueCount(3); + value.setValueCount(3); + variant.setValueCount(3); + root.setRowCount(3); + } + @Test public void testCreateWriter() throws IOException { Path outputPath = tempDir.resolve("test_create.vortex"); @@ -155,4 +203,53 @@ public void testWriteBatch() throws IOException { } } } + + @Test + public void testParquetVariantRoundTrip() throws IOException { + Path outputPath = tempDir.resolve("test_parquet_variant.vortex"); + String writePath = outputPath.toAbsolutePath().toUri().toString(); + + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Schema schema = parquetVariantSchema(); + + Session session = Session.create(); + try (VortexWriter writer = VortexWriter.create(session, writePath, schema, new HashMap<>(), allocator); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + populateParquetVariantRoot(root); + + try (ArrowArray arrowArray = ArrowArray.allocateNew(allocator); + ArrowSchema arrowSchemaFfi = ArrowSchema.allocateNew(allocator)) { + Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchemaFfi); + writer.writeBatch(arrowArray.memoryAddress(), arrowSchemaFfi.memoryAddress()); + } + } + + assertTrue(Files.exists(outputPath), "output file should exist"); + + DataSource ds = DataSource.open(session, writePath); + Field dataSourceField = ds.arrowSchema(allocator).findField("variant"); + assertEquals( + PARQUET_VARIANT_EXTENSION_NAME, dataSourceField.getMetadata().get(ARROW_EXTENSION_NAME)); + + Scan scan = ds.scan(ScanOptions.of()); + Field scanField = scan.arrowSchema(allocator).findField("variant"); + assertEquals(PARQUET_VARIANT_EXTENSION_NAME, scanField.getMetadata().get(ARROW_EXTENSION_NAME)); + + while (scan.hasNext()) { + Partition p = scan.next(); + try (ArrowReader reader = p.scanArrow(allocator)) { + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot resultRoot = reader.getVectorSchemaRoot(); + StructVector variant = (StructVector) resultRoot.getVector("variant"); + VarBinaryVector metadata = variant.getChild("metadata", VarBinaryVector.class); + VarBinaryVector value = variant.getChild("value", VarBinaryVector.class); + + assertArrayEquals(VARIANT_METADATA, metadata.get(0)); + assertArrayEquals(VARIANT_INT8_42, value.get(0)); + assertArrayEquals(VARIANT_METADATA, metadata.get(1)); + assertArrayEquals(VARIANT_TRUE, value.get(1)); + assertTrue(variant.isNull(2)); + } + } + } } diff --git a/java/vortex-spark/build.gradle.kts b/java/vortex-spark/build.gradle.kts index 97527a07ffe..9dc46f4c6d9 100644 --- a/java/vortex-spark/build.gradle.kts +++ b/java/vortex-spark/build.gradle.kts @@ -70,7 +70,9 @@ mavenPublishing { publishToMavenCentral() - signAllPublications() + if (!project.hasProperty("skip.signing")) { + signAllPublications() + } pom { name = "vortex-spark_$scalaVersion" diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/read/SparkPredicateToVortexExpression.java b/java/vortex-spark/src/main/java/dev/vortex/spark/read/SparkPredicateToVortexExpression.java index 8d3fe697153..781c008c79a 100644 --- a/java/vortex-spark/src/main/java/dev/vortex/spark/read/SparkPredicateToVortexExpression.java +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/read/SparkPredicateToVortexExpression.java @@ -6,6 +6,13 @@ import dev.vortex.api.Expression; import dev.vortex.api.Expression.BinaryOp; import dev.vortex.api.Expression.TimeUnit; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; import org.apache.spark.sql.connector.expressions.Literal; import org.apache.spark.sql.connector.expressions.NamedReference; import org.apache.spark.sql.connector.expressions.filter.AlwaysFalse; @@ -33,14 +40,6 @@ import org.apache.spark.sql.types.TimestampType; import org.apache.spark.unsafe.types.UTF8String; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; - /** * Translates {@link Predicate Spark V2 predicates} into Vortex {@link Expression}s for predicate pushdown. * @@ -50,16 +49,15 @@ */ final class SparkPredicateToVortexExpression { - private SparkPredicateToVortexExpression() { - } + private SparkPredicateToVortexExpression() {} /** * Returns true if the given Spark predicate can be translated to a Vortex expression and every named reference * resolves to a real field path under {@code dataColumnTypes}. * *

{@code dataColumnTypes} maps each pushable top-level column name to its top-level Spark {@link DataType}; - * partition columns and columns the scan does not project should not appear in the map. For nested references - * (for example {@code info.email}) the validator walks the named reference part by part, descending into + * partition columns and columns the scan does not project should not appear in the map. For nested references (for + * example {@code info.email}) the validator walks the named reference part by part, descending into * {@link StructType} fields so that {@code info} must be a struct that contains an {@code email} field. * *

This is the cheap check used in {@code SupportsPushDownV2Filters.pushPredicates} to decide which predicates @@ -77,8 +75,7 @@ static boolean isPushable(Predicate predicate, Map dataColumnT /** * Walks {@code parts} against {@code dataColumnTypes}, descending through {@link StructType} fields for - * dot-separated nested references. Returns true only when every part resolves to an actual field in the - * schema. + * dot-separated nested references. Returns true only when every part resolves to an actual field in the schema. */ private static boolean resolveFieldPath(String[] parts, Map dataColumnTypes) { if (parts.length == 0) { @@ -102,7 +99,9 @@ private static boolean resolveFieldPath(String[] parts, Map da } private static Optional findField(StructType struct, String name) { - return Arrays.stream(struct.fields()).filter(structField -> structField.name().equals(name)).findFirst(); + return Arrays.stream(struct.fields()) + .filter(structField -> structField.name().equals(name)) + .findFirst(); } private static boolean isStructurallyPushable(Predicate predicate) { @@ -135,7 +134,7 @@ private static boolean isStructurallyPushable(Predicate predicate) { yield true; } case "STARTS_WITH", "ENDS_WITH", "CONTAINS" -> - children.length == 2 && isPushableFieldRef(children[0]) && isPushableStringLiteral(children[1]); + children.length == 2 && isPushableFieldRef(children[0]) && isPushableStringLiteral(children[1]); // `BOOLEAN_EXPRESSION` wraps a bare boolean-valued child. We only handle the case // where the child itself is a field reference (e.g. `WHERE bool_col`). case "BOOLEAN_EXPRESSION" -> children.length == 1 && isPushableFieldRef(children[0]); @@ -178,12 +177,12 @@ static Optional convert(Predicate predicate) { case "=", "<>", "!=", ">", ">=", "<", "<=" -> convertComparison(predicate.name(), children); case "IS_NULL" -> children.length == 1 ? columnOf(children[0]).map(Expression::isNull) : Optional.empty(); case "IS_NOT_NULL" -> - children.length == 1 ? columnOf(children[0]).map(Expression::isNotNull) : Optional.empty(); + children.length == 1 ? columnOf(children[0]).map(Expression::isNotNull) : Optional.empty(); case "IN" -> convertIn(children); case "STARTS_WITH" -> - convertStringMatch(children, /* leadingWildcard= */ false, /* trailingWildcard= */ true); + convertStringMatch(children, /* leadingWildcard= */ false, /* trailingWildcard= */ true); case "ENDS_WITH" -> - convertStringMatch(children, /* leadingWildcard= */ true, /* trailingWildcard= */ false); + convertStringMatch(children, /* leadingWildcard= */ true, /* trailingWildcard= */ false); case "CONTAINS" -> convertStringMatch(children, /* leadingWildcard= */ true, /* trailingWildcard= */ true); case "BOOLEAN_EXPRESSION" -> children.length == 1 ? columnOf(children[0]) : Optional.empty(); default -> Optional.empty(); @@ -327,9 +326,7 @@ private static boolean isFieldRefExpr(org.apache.spark.sql.connector.expressions return expr instanceof NamedReference; } - /** - * Returns the Vortex column expression for a Spark named reference, walking nested struct fields. - */ + /** Returns the Vortex column expression for a Spark named reference, walking nested struct fields. */ private static Optional columnOf(org.apache.spark.sql.connector.expressions.Expression expr) { if (!(expr instanceof NamedReference)) { return Optional.empty(); @@ -501,9 +498,7 @@ private static Optional convertLiteral(Object value, DataType dataTy return Optional.empty(); } - /** - * Extract the unscaled integer value of a Spark decimal literal at the supplied {@code scale}. - */ + /** Extract the unscaled integer value of a Spark decimal literal at the supplied {@code scale}. */ private static BigInteger unscaledValueOf(Object value, int scale) { BigDecimal decimal; if (value instanceof Decimal) { diff --git a/scripts/compare-benchmark-jsons.py b/scripts/compare-benchmark-jsons.py index 69009a5494a..7d560c144a3 100644 --- a/scripts/compare-benchmark-jsons.py +++ b/scripts/compare-benchmark-jsons.py @@ -4,6 +4,7 @@ # "numpy", # "pandas", # "tabulate", +# "orjson" # ] # /// @@ -18,6 +19,7 @@ from typing import Any import numpy as np +import orjson import pandas as pd # Analysis overview: @@ -59,9 +61,7 @@ def extract_dataset_key(df: pd.DataFrame) -> pd.DataFrame: if "dataset" not in df.columns: df["dataset_key"] = pd.NA else: - df["dataset_key"] = df["dataset"].apply( - lambda x: str(sorted(x.items())) if pd.notna(x) and isinstance(x, dict) else pd.NA - ) + df["dataset_key"] = df["dataset"].apply(dataset_key) return df @@ -77,6 +77,126 @@ def split_file_size_rows(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: return df[mask].copy(), df[~mask].copy() +def identity_value(value: Any) -> Any: + """Normalize missing values so benchmark identities compare reliably.""" + + return None if pd.isna(value) else value + + +def dataset_key(value: Any) -> str | None: + """Normalize dataset metadata into the join-key representation.""" + + if isinstance(value, dict): + return str(sorted(value.items())) + return None + + +def benchmark_identity(row: Any) -> tuple[Any, Any, Any] | None: + """Return the timing-row identity used to find a matching baseline.""" + + if row.get("metric") == FILE_SIZE_METRIC or row.get("file_size") is not None: + return None + + name = row.get("name") + if name is None: + return None + + return ( + identity_value(name), + identity_value(row.get("storage")), + dataset_key(row.get("dataset")), + ) + + +def benchmark_identity_rows(df: pd.DataFrame) -> pd.DataFrame: + """Return timing rows with the identity used to match a PR benchmark.""" + + _file_size_rows, timing_rows = split_file_size_rows(df) + if timing_rows.empty or "name" not in timing_rows.columns: + return pd.DataFrame(columns=["commit_id", "benchmark_identity"]) + + timing_rows = timing_rows.copy() + if "storage" not in timing_rows.columns: + timing_rows["storage"] = pd.NA + if "commit_id" not in timing_rows.columns: + timing_rows["commit_id"] = pd.NA + + timing_rows = extract_dataset_key(timing_rows) + timing_rows["benchmark_identity"] = [ + tuple(identity_value(row[column]) for column in ("name", "storage", "dataset_key")) + for _, row in timing_rows.iterrows() + ] + + return timing_rows[["commit_id", "benchmark_identity"]] + + +def read_jsonl_rows_for_commit(path: str, commit_id: str) -> pd.DataFrame: + """Read only rows matching a commit from a JSONL benchmark history.""" + + rows = [] + with open(path, encoding="utf-8") as lines: + for line in lines: + if '"commit_id"' not in line or f'"{commit_id}"' not in line: + continue + record = orjson.loads(line) + if record.get("commit_id") == commit_id: + rows.append(record) + return pd.DataFrame(rows) + + +def read_latest_baseline_rows(path: str, pr: pd.DataFrame) -> pd.DataFrame: + """Read rows from the latest history commit matching the PR benchmark.""" + + pr_identities = set(benchmark_identity_rows(pr)["benchmark_identity"]) + if not pr_identities: + return pd.read_json(path, lines=True) + + baseline_commit_id = None + with open(path, encoding="utf-8") as lines: + for line in lines: + if '"name"' not in line or '"commit_id"' not in line: + continue + record = orjson.loads(line) + if benchmark_identity(record) in pr_identities: + commit_id = record.get("commit_id") + if commit_id is not None: + baseline_commit_id = commit_id + + if baseline_commit_id is None: + raise ValueError("No baseline rows found for the benchmark under test") + + return read_jsonl_rows_for_commit(path, baseline_commit_id) + + +def select_latest_baseline_rows(base: pd.DataFrame, pr: pd.DataFrame) -> pd.DataFrame: + """Select rows from the latest baseline commit containing this benchmark. + + The persisted benchmark history is append-only. A row only appears after + that benchmark job uploaded results, so the newest commit with matching row + identities is the latest successful baseline for the benchmark under test. + """ + + if base.empty or "commit_id" not in base.columns: + return base + + commit_ids = base["commit_id"].dropna().unique() + if len(commit_ids) <= 1: + return base + + pr_identities = set(benchmark_identity_rows(pr)["benchmark_identity"]) + if not pr_identities: + return base + + base_identities = benchmark_identity_rows(base) + matches = base_identities[base_identities["benchmark_identity"].isin(pr_identities)] + matches = matches[matches["commit_id"].notna()] + if matches.empty: + raise ValueError("No baseline rows found for the benchmark under test") + + baseline_commit_id = matches["commit_id"].iloc[-1] + return base[base["commit_id"] == baseline_commit_id].copy() + + def extract_target_fields(name: str) -> pd.Series: """Parse query, engine, and format from the benchmark name.""" @@ -702,8 +822,8 @@ def main() -> None: benchmark_name = sys.argv[3] if len(sys.argv) > 3 else "" - base = pd.read_json(sys.argv[1], lines=True) pr = pd.read_json(sys.argv[2], lines=True) + base = read_latest_baseline_rows(sys.argv[1], pr) base_commit_id = set(base["commit_id"].unique()) pr_commit_id = set(pr["commit_id"].unique()) @@ -783,22 +903,6 @@ def main() -> None: print("---") print("") - if statistical_analysis is not None: - alpha_rows = statistical_analysis["detail_df"][~statistical_analysis["detail_df"]["is_control"]].copy() - if not alpha_rows.empty: - alpha_rows = alpha_rows.sort_values(["query", "engine", "file_format"]) - alpha_table = pd.DataFrame( - { - "Query": alpha_rows["query"].astype(int), - "Config": alpha_rows["combo"], - "Raw Δ": alpha_rows["ratio"].map(format_ratio_change), - "Control Δ": alpha_rows["beta_ratio"].map(format_ratio_change), - "Attributed α": alpha_rows["alpha_ratio"].map(format_ratio_change), - "Noise floor": alpha_rows["noise_floor_ratio"].map(format_ratio_change), - "Significant?": alpha_rows["signal"].map(format_signal), - } - ) - grouped_tables = df3.groupby(["engine", "file_format"], dropna=False, sort=False) for engine, file_format in sorted(grouped_tables.groups.keys(), key=group_sort_key): group_df = grouped_tables.get_group((engine, file_format)).sort_values("name") @@ -847,21 +951,6 @@ def main() -> None: print("") print(file_size_report) - if statistical_analysis is not None and not alpha_rows.empty: - print("

") - print("Full attributed analysis") - print("") - print("
") - print("") - print( - alpha_table.to_markdown( - index=False, - tablefmt="github", - ) - ) - print("") - print("
") - if __name__ == "__main__": main() diff --git a/scripts/tests/test_benchmark_reporting.py b/scripts/tests/test_benchmark_reporting.py index ac356eff130..7925161e91e 100644 --- a/scripts/tests/test_benchmark_reporting.py +++ b/scripts/tests/test_benchmark_reporting.py @@ -33,6 +33,112 @@ def timing_row(name: str, base: int, pr: int) -> dict[str, object]: } +def stored_timing_row( + commit: str, + name: str, + value: int, + storage: str | None = None, + dataset: dict[str, object] | None = None, +) -> dict[str, object]: + row: dict[str, object] = { + "name": name, + "unit": "ns", + "value": value, + "all_runtimes": [value, value, value], + "commit_id": commit, + } + if storage is not None: + row["storage"] = storage + if dataset is not None: + row["dataset"] = dataset + return row + + +def test_select_latest_baseline_rows_uses_latest_matching_benchmark_commit() -> None: + compare = load_compare_module() + history = pd.DataFrame( + [ + stored_timing_row( + "base-old", + "tpch_q01/datafusion:parquet", + 100, + "nvme", + {"scale_factor": "1.0"}, + ), + file_size_record_for("base-old", 100, "tpch", "1.0", "vortex-file-compressed", "part-0.vortex"), + stored_timing_row( + "base-current", + "tpch_q01/datafusion:parquet", + 110, + "nvme", + {"scale_factor": "1.0"}, + ), + file_size_record_for("base-current", 120, "tpch", "1.0", "vortex-file-compressed", "part-0.vortex"), + stored_timing_row("base-other", "clickbench_q01/datafusion:parquet", 200, "nvme"), + ] + ) + pr = pd.DataFrame( + [ + stored_timing_row( + "pr-sha", + "tpch_q01/datafusion:parquet", + 115, + "nvme", + {"scale_factor": "1.0"}, + ), + ] + ) + + selected = compare.select_latest_baseline_rows(history, pr) + + assert set(selected["commit_id"]) == {"base-current"} + assert len(selected) == 2 + + +def test_read_latest_baseline_rows_streams_latest_matching_benchmark_commit(tmp_path: Path) -> None: + compare = load_compare_module() + history_path = tmp_path / "history.jsonl" + history_rows = [ + stored_timing_row( + "base-old", + "tpch_q01/datafusion:parquet", + 100, + "nvme", + {"scale_factor": "1.0"}, + ), + file_size_record_for("base-old", 100, "tpch", "1.0", "vortex-file-compressed", "part-0.vortex"), + stored_timing_row( + "base-current", + "tpch_q01/datafusion:parquet", + 110, + "nvme", + {"scale_factor": "1.0"}, + ), + file_size_record_for("base-current", 120, "tpch", "1.0", "vortex-file-compressed", "part-0.vortex"), + stored_timing_row("base-other", "clickbench_q01/datafusion:parquet", 200, "nvme"), + ] + history_path.write_text( + "".join(f"{json.dumps(row)}\n" for row in history_rows), + encoding="utf-8", + ) + pr = pd.DataFrame( + [ + stored_timing_row( + "pr-sha", + "tpch_q01/datafusion:parquet", + 115, + "nvme", + {"scale_factor": "1.0"}, + ), + ] + ) + + selected = compare.read_latest_baseline_rows(history_path, pr) + + assert set(selected["commit_id"]) == {"base-current"} + assert len(selected) == 2 + + def test_within_engine_analysis_uses_each_engines_own_parquet_control() -> None: compare = load_compare_module() rows = [ diff --git a/uv.lock b/uv.lock index be0882bdf37..ef6e5670aaf 100644 --- a/uv.lock +++ b/uv.lock @@ -1761,15 +1761,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]] diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index e103097f8b6..4ec8a83575e 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -45,7 +45,6 @@ humansize = { workspace = true } inventory = { workspace = true } itertools = { workspace = true } jiff = { workspace = true } -multiversion = { workspace = true } num-traits = { workspace = true } num_enum = { workspace = true } parking_lot = { workspace = true } @@ -95,6 +94,7 @@ rand_distr = { workspace = true } rstest = { workspace = true } serde_json = { workspace = true } serde_test = { workspace = true } +test-with = { workspace = true } vortex-array = { path = ".", features = ["_test-harness", "table-display"] } [[bench]] @@ -105,6 +105,10 @@ harness = false name = "aggregate_sum" harness = false +[[bench]] +name = "aggregate_grouped" +harness = false + [[bench]] name = "cast_primitive" harness = false @@ -121,6 +125,10 @@ harness = false name = "compare" harness = false +[[bench]] +name = "interleave" +harness = false + [[bench]] name = "take_patches" harness = false @@ -187,6 +195,14 @@ harness = false name = "varbinview_zip" harness = false +[[bench]] +name = "bool_zip" +harness = false + +[[bench]] +name = "primitive_zip" +harness = false + [[bench]] name = "take_primitive" harness = false diff --git a/vortex-array/benches/aggregate_grouped.rs b/vortex-array/benches/aggregate_grouped.rs new file mode 100644 index 00000000000..b067314c1d9 --- /dev/null +++ b/vortex-array/benches/aggregate_grouped.rs @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#![expect(clippy::cast_possible_truncation)] +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use rand::RngExt; +use rand::SeedableRng; +use rand::rngs::StdRng; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::LEGACY_SESSION; +use vortex_array::VortexSessionExecute; +use vortex_array::aggregate_fn::AggregateFnVTable; +use vortex_array::aggregate_fn::DynGroupedAccumulator; +use vortex_array::aggregate_fn::EmptyOptions; +use vortex_array::aggregate_fn::GroupedAccumulator; +use vortex_array::aggregate_fn::fns::count::Count; +use vortex_array::aggregate_fn::fns::sum::Sum; +use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::dtype::DType; +use vortex_array::validity::Validity; +use vortex_buffer::Buffer; + +fn main() { + divan::main(); +} + +const GROUP_COUNT: usize = 128; +const GROUP_SIZE_SEED: u64 = 42; +const MIN_VALUES_PER_GROUP: usize = 1; +const MAX_VALUES_PER_GROUP: usize = 15; + +fn random_group_sizes() -> Vec { + let mut rng = StdRng::seed_from_u64(GROUP_SIZE_SEED); + (0..GROUP_COUNT) + .map(|_| rng.random_range(MIN_VALUES_PER_GROUP..=MAX_VALUES_PER_GROUP)) + .collect() +} + +fn total_element_count(group_sizes: &[usize]) -> usize { + group_sizes.iter().sum() +} + +fn contiguous_list_view(elements: ArrayRef, group_sizes: &[usize]) -> ArrayRef { + let mut offset = 0usize; + let offsets: Buffer = group_sizes + .iter() + .map(|&size| { + let current_offset = offset; + offset += size; + current_offset as u32 + }) + .collect(); + let sizes: Buffer = group_sizes.iter().map(|&size| size as u32).collect(); + + assert_eq!(elements.len(), total_element_count(group_sizes)); + + ListViewArray::try_new( + elements, + offsets.into_array(), + sizes.into_array(), + Validity::NonNullable, + ) + .unwrap() + .into_array() +} + +fn i32_nullable_all_valid_input() -> ArrayRef { + let group_sizes = random_group_sizes(); + let element_count = total_element_count(&group_sizes); + let values: Buffer = (0..element_count) + .map(|i| (i % 1024) as i32 - 512) + .collect(); + let validity = Validity::from_iter(std::iter::repeat_n(true, element_count)); + contiguous_list_view( + PrimitiveArray::new(values, validity).into_array(), + &group_sizes, + ) +} + +fn i32_clustered_nulls_input() -> ArrayRef { + let group_sizes = random_group_sizes(); + let element_count = total_element_count(&group_sizes); + let values = (0..element_count).map(|i| { + if (i / 16) % 8 == 0 { + None + } else { + Some((i % 1024) as i32 - 512) + } + }); + contiguous_list_view( + PrimitiveArray::from_option_iter(values).into_array(), + &group_sizes, + ) +} + +fn f64_all_valid_input() -> ArrayRef { + let group_sizes = random_group_sizes(); + let element_count = total_element_count(&group_sizes); + let mut rng = StdRng::seed_from_u64(GROUP_SIZE_SEED); + let values: Buffer = (0..element_count) + .map(|_| rng.random_range(-1000.0..1000.0)) + .collect(); + contiguous_list_view( + PrimitiveArray::new(values, Validity::NonNullable).into_array(), + &group_sizes, + ) +} + +fn f64_clustered_nulls_input() -> ArrayRef { + let group_sizes = random_group_sizes(); + let element_count = total_element_count(&group_sizes); + let mut rng = StdRng::seed_from_u64(GROUP_SIZE_SEED); + let values = (0..element_count).map(|i| { + if (i / 16) % 8 == 0 { + None + } else { + Some(rng.random_range(-1000.0f64..1000.0)) + } + }); + contiguous_list_view( + PrimitiveArray::from_option_iter(values).into_array(), + &group_sizes, + ) +} + +fn varbinview_input() -> ArrayRef { + let group_sizes = random_group_sizes(); + let element_count = total_element_count(&group_sizes); + let values: Vec = (0..element_count) + .map(|i| format!("value-{i:06}")) + .collect(); + contiguous_list_view( + VarBinViewArray::from_iter_str(values.iter().map(String::as_str)).into_array(), + &group_sizes, + ) +} + +fn list_element_dtype(list_view: &ArrayRef) -> DType { + match list_view.dtype() { + DType::List(element_dtype, _) => element_dtype.as_ref().clone(), + dtype => unreachable!("expected List dtype, got {dtype}"), + } +} + +fn grouped_accumulator(list_view: &ArrayRef, vtable: V) -> ArrayRef +where + V: AggregateFnVTable + Clone, +{ + let mut acc = + GroupedAccumulator::try_new(vtable, EmptyOptions, list_element_dtype(list_view)).unwrap(); + acc.accumulate_list(list_view, &mut LEGACY_SESSION.create_execution_ctx()) + .unwrap(); + divan::black_box(acc.finish().unwrap()) +} + +#[divan::bench] +fn sum_i32_nullable_all_valid(bencher: Bencher) { + let input = i32_nullable_all_valid_input(); + bencher + .with_inputs(|| &input) + .bench_refs(|input| grouped_accumulator(input, Sum)); +} + +#[divan::bench] +fn sum_i32_clustered_nulls(bencher: Bencher) { + let input = i32_clustered_nulls_input(); + bencher + .with_inputs(|| &input) + .bench_refs(|input| grouped_accumulator(input, Sum)); +} + +#[divan::bench] +fn sum_f64_all_valid(bencher: Bencher) { + let input = f64_all_valid_input(); + bencher + .with_inputs(|| &input) + .bench_refs(|input| grouped_accumulator(input, Sum)); +} + +#[divan::bench] +fn sum_f64_clustered_nulls(bencher: Bencher) { + let input = f64_clustered_nulls_input(); + bencher + .with_inputs(|| &input) + .bench_refs(|input| grouped_accumulator(input, Sum)); +} + +#[divan::bench] +fn count_i32_clustered_nulls(bencher: Bencher) { + let input = i32_clustered_nulls_input(); + bencher + .with_inputs(|| &input) + .bench_refs(|input| grouped_accumulator(input, Count)); +} + +#[divan::bench] +fn count_varbinview(bencher: Bencher) { + let input = varbinview_input(); + bencher + .with_inputs(|| &input) + .bench_refs(|input| grouped_accumulator(input, Count)); +} diff --git a/vortex-array/benches/aggregate_max.rs b/vortex-array/benches/aggregate_max.rs index 8962ade2974..14bf13792dd 100644 --- a/vortex-array/benches/aggregate_max.rs +++ b/vortex-array/benches/aggregate_max.rs @@ -25,11 +25,13 @@ fn max_i32(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(1); let data: Vec = (0..N).map(|_| rng.random::()).collect(); bencher - .with_inputs(|| PrimitiveArray::from_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_max::(&mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_max::(ctx)); } #[divan::bench] @@ -37,11 +39,13 @@ fn max_i64(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(2); let data: Vec = (0..N).map(|_| rng.random::()).collect(); bencher - .with_inputs(|| PrimitiveArray::from_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_max::(&mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_max::(ctx)); } #[divan::bench] @@ -49,11 +53,13 @@ fn max_f64(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(3); let data: Vec = (0..N).map(|_| rng.random::()).collect(); bencher - .with_inputs(|| PrimitiveArray::from_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_max::(&mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_max::(ctx)); } // Clustered nulls: long valid runs broken up by null blocks (run-based path's best case). @@ -70,11 +76,13 @@ fn max_i32_nulls_clustered(bencher: Bencher) { }) .collect(); bencher - .with_inputs(|| PrimitiveArray::from_option_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_max::(&mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_option_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_max::(ctx)); } // Scattered nulls: ~50% random nulls producing many short runs (run-based path's worst case). @@ -85,9 +93,11 @@ fn max_i32_nulls_scattered(bencher: Bencher) { .map(|_| rng.random_bool(0.5).then(|| rng.random::())) .collect(); bencher - .with_inputs(|| PrimitiveArray::from_option_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_max::(&mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_option_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_max::(ctx)); } diff --git a/vortex-array/benches/aggregate_sum.rs b/vortex-array/benches/aggregate_sum.rs index db4bf5b284a..b076bb80b5f 100644 --- a/vortex-array/benches/aggregate_sum.rs +++ b/vortex-array/benches/aggregate_sum.rs @@ -26,11 +26,13 @@ fn sum_i32(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(1); let data: Vec = (0..N).map(|_| rng.random_range(-1000..1000)).collect(); bencher - .with_inputs(|| PrimitiveArray::from_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_as::(Stat::Sum, &mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_as::(Stat::Sum, ctx)); } #[divan::bench] @@ -38,11 +40,13 @@ fn sum_u32(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(2); let data: Vec = (0..N).map(|_| rng.random_range(0..2000)).collect(); bencher - .with_inputs(|| PrimitiveArray::from_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_as::(Stat::Sum, &mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_as::(Stat::Sum, ctx)); } #[divan::bench] @@ -50,11 +54,49 @@ fn sum_i64(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(3); let data: Vec = (0..N).map(|_| rng.random_range(-1000..1000)).collect(); bencher - .with_inputs(|| PrimitiveArray::from_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_as::(Stat::Sum, &mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_as::(Stat::Sum, ctx)); +} + +#[divan::bench] +fn sum_f64(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(6); + let data: Vec = (0..N).map(|_| rng.random_range(-1000.0..1000.0)).collect(); + bencher + .with_inputs(|| { + ( + PrimitiveArray::from_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_as::(Stat::Sum, ctx)); +} + +#[divan::bench] +fn sum_f64_nulls_clustered(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(7); + let data: Vec> = (0..N) + .map(|i| { + if (i / 64) % 10 == 0 { + None + } else { + Some(rng.random_range(-1000.0..1000.0)) + } + }) + .collect(); + bencher + .with_inputs(|| { + ( + PrimitiveArray::from_option_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_as::(Stat::Sum, ctx)); } // Clustered nulls: long runs of valid values broken up by occasional null blocks. This is the @@ -72,11 +114,13 @@ fn sum_i32_nulls_clustered(bencher: Bencher) { }) .collect(); bencher - .with_inputs(|| PrimitiveArray::from_option_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_as::(Stat::Sum, &mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_option_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_as::(Stat::Sum, ctx)); } // Scattered nulls: ~50% nulls placed at random, producing many short runs. This is the worst case @@ -88,9 +132,11 @@ fn sum_i32_nulls_scattered(bencher: Bencher) { .map(|_| rng.random_bool(0.5).then(|| rng.random_range(-1000..1000))) .collect(); bencher - .with_inputs(|| PrimitiveArray::from_option_iter(data.iter().copied()).into_array()) - .bench_refs(|a| { - a.statistics() - .compute_as::(Stat::Sum, &mut SESSION.create_execution_ctx()) - }); + .with_inputs(|| { + ( + PrimitiveArray::from_option_iter(data.iter().copied()).into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(a, ctx)| a.statistics().compute_as::(Stat::Sum, ctx)); } diff --git a/vortex-array/benches/bool_zip.rs b/vortex-array/benches/bool_zip.rs new file mode 100644 index 00000000000..c537d7b074f --- /dev/null +++ b/vortex-array/benches/bool_zip.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::LEGACY_SESSION; +use vortex_array::RecursiveCanonical; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_mask::Mask; + +fn main() { + divan::main(); +} + +const LEN: usize = 65_536; + +/// Fragmented (alternating) mask: the worst case for the generic per-run builder this kernel +/// replaces. The branchless bitmap blend is mask-shape-independent, so one shape suffices. +fn mask() -> Mask { + Mask::from_iter((0..LEN).map(|i| i.is_multiple_of(2))) +} + +#[divan::bench] +fn nonnull(bencher: Bencher) { + let if_true = BoolArray::from_iter((0..LEN).map(|i| i.is_multiple_of(2))).into_array(); + let if_false = BoolArray::from_iter((0..LEN).map(|i| i.is_multiple_of(3))).into_array(); + run(bencher, if_true, if_false); +} + +#[divan::bench] +fn nullable(bencher: Bencher) { + let if_true = BoolArray::from_iter( + (0..LEN).map(|i| (!i.is_multiple_of(7)).then_some(i.is_multiple_of(2))), + ) + .into_array(); + let if_false = BoolArray::from_iter( + (0..LEN).map(|i| (!i.is_multiple_of(5)).then_some(i.is_multiple_of(3))), + ) + .into_array(); + run(bencher, if_true, if_false); +} + +fn run(bencher: Bencher, if_true: ArrayRef, if_false: ArrayRef) { + let mask = mask(); + bencher + .with_inputs(|| { + ( + if_true.clone(), + if_false.clone(), + mask.clone().into_array(), + LEGACY_SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(t, f, m, ctx)| { + m.zip(t.clone(), f.clone()) + .unwrap() + .execute::(ctx) + .unwrap(); + }); +} diff --git a/vortex-array/benches/cast_primitive.rs b/vortex-array/benches/cast_primitive.rs index 86895fb2ce7..060a8290a2a 100644 --- a/vortex-array/benches/cast_primitive.rs +++ b/vortex-array/benches/cast_primitive.rs @@ -1,11 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::sync::LazyLock; + use divan::Bencher; use rand::prelude::*; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::builtins::ArrayBuiltins; @@ -13,6 +14,8 @@ use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::expr::stats::Stat; +use vortex_array::session::ArraySession; +use vortex_session::VortexSession; fn main() { divan::main(); @@ -20,6 +23,9 @@ fn main() { const N: usize = 100_000; +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench] fn cast_u16_to_u32(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(42); @@ -34,15 +40,14 @@ fn cast_u16_to_u32(bencher: Bencher) { .into_array(); // Pre-compute min/max so values_fit_in is a cache hit during the benchmark. arr.statistics() - .compute_all( - &[Stat::Min, Stat::Max], - &mut LEGACY_SESSION.create_execution_ctx(), - ) + .compute_all(&[Stat::Min, Stat::Max], &mut SESSION.create_execution_ctx()) .ok(); - bencher.with_inputs(|| arr.clone()).bench_refs(|a| { - #[expect(clippy::unwrap_used)] - a.cast(DType::Primitive(PType::U32, Nullability::Nullable)) - .unwrap() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - }); + bencher + .with_inputs(|| (arr.clone(), SESSION.create_execution_ctx())) + .bench_refs(|(a, ctx)| { + #[expect(clippy::unwrap_used)] + a.cast(DType::Primitive(PType::U32, Nullability::Nullable)) + .unwrap() + .execute::(ctx) + }); } diff --git a/vortex-array/benches/chunked_fsl_canonicalize.rs b/vortex-array/benches/chunked_fsl_canonicalize.rs index d267415cc4e..ac9962046de 100644 --- a/vortex-array/benches/chunked_fsl_canonicalize.rs +++ b/vortex-array/benches/chunked_fsl_canonicalize.rs @@ -10,20 +10,26 @@ #![expect(clippy::cast_possible_truncation)] #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ChunkedArray; use vortex_array::arrays::FixedSizeListArray; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + /// Number of lists in each chunk. const LISTS_PER_CHUNK: usize = 1_000; @@ -60,7 +66,7 @@ fn canonicalize(bencher: Bencher, num_chunks: usize) { let chunked = create_chunked_fsl(LIST_SIZE, num_chunks).into_array(); bencher - .with_inputs(|| (&chunked, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&chunked, SESSION.create_execution_ctx())) .bench_refs(|(array, execution_ctx)| { array.clone().execute::(execution_ctx).unwrap() }); diff --git a/vortex-array/benches/dict_compare.rs b/vortex-array/benches/dict_compare.rs index 54a3faf8224..401393495dd 100644 --- a/vortex-array/benches/dict_compare.rs +++ b/vortex-array/benches/dict_compare.rs @@ -4,10 +4,10 @@ #![expect(clippy::unwrap_used)] use std::str::from_utf8; +use std::sync::LazyLock; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::accessor::ArrayAccessor; @@ -22,12 +22,16 @@ use vortex_array::expr::eq; use vortex_array::expr::lit; use vortex_array::expr::root; use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::session::ArraySession; use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + const LENGTH_AND_UNIQUE_VALUES: &[(usize, usize)] = &[ // length, unique_values (10_000, 2), @@ -51,7 +55,7 @@ fn bench_compare_primitive(bencher: divan::Bencher, (len, uniqueness): (usize, u let primitive_arr = gen_primitive_for_dict::(len, uniqueness); let dict = dict_encode( &primitive_arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap(); let value = primitive_arr.as_slice::()[0]; @@ -74,7 +78,7 @@ fn bench_compare_varbin(bencher: divan::Bencher, (len, uniqueness): (usize, usiz let varbin_arr = VarBinArray::from(gen_varbin_words(len, uniqueness)); let dict = dict_encode( &varbin_arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap(); let bytes = varbin_arr.with_iterator(|i| i.next().unwrap().unwrap().to_vec()); @@ -98,7 +102,7 @@ fn bench_compare_varbinview(bencher: divan::Bencher, (len, uniqueness): (usize, let varbinview_arr = VarBinViewArray::from_iter_str(gen_varbin_words(len, uniqueness)); let dict = dict_encode( &varbinview_arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap(); let bytes = varbinview_arr.with_iterator(|i| i.next().unwrap().unwrap().to_vec()); @@ -137,7 +141,7 @@ fn bench_compare_sliced_dict_primitive( let primitive_arr = gen_primitive_for_dict::(codes_len.max(values_len), values_len); let dict = dict_encode( &primitive_arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap(); let dict = dict.into_array().slice(0..codes_len).unwrap(); @@ -163,7 +167,7 @@ fn bench_compare_sliced_dict_varbinview( let varbin_arr = VarBinArray::from(gen_varbin_words(codes_len.max(values_len), values_len)); let dict = dict_encode( &varbin_arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap(); let dict = dict.into_array().slice(0..codes_len).unwrap(); diff --git a/vortex-array/benches/dict_compress.rs b/vortex-array/benches/dict_compress.rs index cf7f647a7e0..21d427bd7f8 100644 --- a/vortex-array/benches/dict_compress.rs +++ b/vortex-array/benches/dict_compress.rs @@ -3,12 +3,13 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::distr::Distribution; use rand::distr::StandardUniform; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::VarBinArray; use vortex_array::arrays::VarBinViewArray; @@ -16,6 +17,8 @@ use vortex_array::arrays::dict_test::gen_primitive_for_dict; use vortex_array::arrays::dict_test::gen_varbin_words; use vortex_array::builders::dict::dict_encode; use vortex_array::dtype::NativePType; +use vortex_array::session::ArraySession; +use vortex_session::VortexSession; fn main() { divan::main(); @@ -35,6 +38,9 @@ const BENCH_ARGS: &[(usize, usize)] = &[ (10_000, 512), ]; +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench(types = [u8, f32, i64], args = BENCH_ARGS)] fn encode_primitives(bencher: Bencher, (len, unique_values): (usize, usize)) where @@ -43,36 +49,27 @@ where { let primitive_arr = gen_primitive_for_dict::(len, unique_values); - bencher.with_inputs(|| &primitive_arr).bench_refs(|arr| { - dict_encode( - &arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), - ) - }); + bencher + .with_inputs(|| (&primitive_arr, SESSION.create_execution_ctx())) + .bench_refs(|(arr, ctx)| dict_encode(&arr.clone().into_array(), ctx)); } #[divan::bench(args = BENCH_ARGS)] fn encode_varbin(bencher: Bencher, (len, unique_values): (usize, usize)) { let varbin_arr = VarBinArray::from(gen_varbin_words(len, unique_values)); - bencher.with_inputs(|| &varbin_arr).bench_refs(|arr| { - dict_encode( - &arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), - ) - }); + bencher + .with_inputs(|| (&varbin_arr, SESSION.create_execution_ctx())) + .bench_refs(|(arr, ctx)| dict_encode(&arr.clone().into_array(), ctx)); } #[divan::bench(args = BENCH_ARGS)] fn encode_varbinview(bencher: Bencher, (len, unique_values): (usize, usize)) { let varbinview_arr = VarBinViewArray::from_iter_str(gen_varbin_words(len, unique_values)); - bencher.with_inputs(|| &varbinview_arr).bench_refs(|arr| { - dict_encode( - &arr.clone().into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), - ) - }); + bencher + .with_inputs(|| (&varbinview_arr, SESSION.create_execution_ctx())) + .bench_refs(|(arr, ctx)| dict_encode(&arr.clone().into_array(), ctx)); } #[divan::bench(types = [u8, f32, i64], args = BENCH_ARGS)] @@ -84,13 +81,13 @@ where let primitive_arr = gen_primitive_for_dict::(len, unique_values); let dict = dict_encode( &primitive_arr.into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap() .into_array(); bencher - .with_inputs(|| (&dict, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&dict, SESSION.create_execution_ctx())) .bench_refs(|(dict, ctx)| (**dict).clone().execute::(ctx)); } @@ -99,13 +96,13 @@ fn decode_varbin(bencher: Bencher, (len, unique_values): (usize, usize)) { let varbin_arr = VarBinArray::from(gen_varbin_words(len, unique_values)); let dict = dict_encode( &varbin_arr.into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap() .into_array(); bencher - .with_inputs(|| (&dict, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&dict, SESSION.create_execution_ctx())) .bench_refs(|(dict, ctx)| (**dict).clone().execute::(ctx)); } @@ -114,12 +111,12 @@ fn decode_varbinview(bencher: Bencher, (len, unique_values): (usize, usize)) { let varbinview_arr = VarBinViewArray::from_iter_str(gen_varbin_words(len, unique_values)); let dict = dict_encode( &varbinview_arr.into_array(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut SESSION.create_execution_ctx(), ) .unwrap() .into_array(); bencher - .with_inputs(|| (&dict, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&dict, SESSION.create_execution_ctx())) .bench_refs(|(dict, ctx)| (**dict).clone().execute::(ctx)); } diff --git a/vortex-array/benches/filter_bool.rs b/vortex-array/benches/filter_bool.rs index 5699fdb19cb..6c240db6b60 100644 --- a/vortex-array/benches/filter_bool.rs +++ b/vortex-array/benches/filter_bool.rs @@ -11,20 +11,26 @@ #![expect(clippy::cast_sign_loss)] #![expect(clippy::cast_precision_loss)] +use std::sync::LazyLock; + use divan::Bencher; use rand::prelude::*; use rand_distr::Zipf; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; +use vortex_array::session::ArraySession; use vortex_buffer::BitBuffer; use vortex_mask::Mask; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + const SIZES: &[usize] = &[1_000, 10_000, 100_000, 250_000]; const DENSITY_SWEEP_SIZE: usize = 100_000; const ARRAY_SEED: u64 = 42; @@ -142,7 +148,7 @@ fn filter_random_by_mostly_true(bencher: Bencher, n: usize) { ( array.clone(), make_mostly_true(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -162,7 +168,7 @@ fn filter_random_by_mostly_false(bencher: Bencher, n: usize) { ( array.clone(), make_mostly_false(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -182,7 +188,7 @@ fn filter_random_by_random(bencher: Bencher, n: usize) { ( array.clone(), make_random(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -202,7 +208,7 @@ fn filter_random_by_correlated_runs(bencher: Bencher, n: usize) { ( array.clone(), make_correlated_runs(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -222,7 +228,7 @@ fn filter_random_by_power_law(bencher: Bencher, n: usize) { ( array.clone(), make_power_law(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -244,7 +250,7 @@ fn filter_powerlaw_by_mostly_true(bencher: Bencher, n: usize) { ( array.clone(), make_mostly_true(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -264,7 +270,7 @@ fn filter_powerlaw_by_mostly_false(bencher: Bencher, n: usize) { ( array.clone(), make_mostly_false(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -284,7 +290,7 @@ fn filter_powerlaw_by_random(bencher: Bencher, n: usize) { ( array.clone(), make_random(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -304,7 +310,7 @@ fn filter_powerlaw_by_correlated_runs(bencher: Bencher, n: usize) { ( array.clone(), make_correlated_runs(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -324,7 +330,7 @@ fn filter_powerlaw_by_power_law(bencher: Bencher, n: usize) { ( array.clone(), make_power_law(n, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -350,7 +356,7 @@ fn density_sweep_random(bencher: Bencher, density: f64) { ( array.clone(), make_density_mask(DENSITY_SWEEP_SIZE, density, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -371,7 +377,7 @@ fn density_sweep_dense_runs(bencher: Bencher, density: f64) { ( array.clone(), make_dense_runs(DENSITY_SWEEP_SIZE, false_rate, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -391,7 +397,7 @@ fn density_sweep_single_slice(bencher: Bencher, density: f64) { ( array.clone(), make_single_slice(DENSITY_SWEEP_SIZE, density), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -415,7 +421,7 @@ fn filter_all_true(bencher: Bencher, n: usize) { ( array.clone(), Mask::new_true(n), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -437,7 +443,7 @@ fn filter_one_false(bencher: Bencher, n: usize) { ( array.clone(), Mask::from_buffer(BitBuffer::from_iter(bits)), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { @@ -457,7 +463,7 @@ fn filter_ultra_sparse(bencher: Bencher, n: usize) { ( array.clone(), make_density_mask(n, 0.0001, &mut mask_rng()), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, m, ctx)| { diff --git a/vortex-array/benches/interleave.rs b/vortex-array/benches/interleave.rs new file mode 100644 index 00000000000..cba7f6a910e --- /dev/null +++ b/vortex-array/benches/interleave.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use rand::RngExt; +use rand::SeedableRng; +use rand::distr::Uniform; +use rand::prelude::StdRng; +use vortex_array::Canonical; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::InterleaveArray; +use vortex_buffer::Buffer; +use vortex_session::VortexSession; + +fn main() { + divan::main(); +} + +const ARRAY_SIZE: usize = 8_192; + +/// Builds `num_branches` boolean value arrays plus random `(array_indices, row_indices)` selectors +/// describing a full random-access gather of `ARRAY_SIZE` output rows. +fn inputs( + num_branches: usize, + nullable: bool, +) -> (Vec, Buffer, Buffer) { + let mut rng = StdRng::seed_from_u64(0); + let bit = Uniform::new(0u8, 2).unwrap(); + + let values = (0..num_branches) + .map(|_| { + if nullable { + BoolArray::from_iter( + (0..ARRAY_SIZE).map(|_| (rng.sample(bit) == 0).then_some(rng.sample(bit) == 0)), + ) + .into_array() + } else { + BoolArray::from_iter((0..ARRAY_SIZE).map(|_| rng.sample(bit) == 0)).into_array() + } + }) + .collect(); + + let branch = Uniform::new(0u32, u32::try_from(num_branches).unwrap()).unwrap(); + let row = Uniform::new(0u32, u32::try_from(ARRAY_SIZE).unwrap()).unwrap(); + let array_indices: Buffer = (0..ARRAY_SIZE).map(|_| rng.sample(branch)).collect(); + let row_indices: Buffer = (0..ARRAY_SIZE).map(|_| rng.sample(row)).collect(); + (values, array_indices, row_indices) +} + +#[divan::bench(args = [2, 4])] +fn interleave_bool(bencher: Bencher, num_branches: usize) { + let (values, array_indices, row_indices) = inputs(num_branches, false); + let session = VortexSession::empty(); + bencher + .with_inputs(|| { + ( + InterleaveArray::try_new( + values.clone(), + array_indices.clone().into_array(), + row_indices.clone().into_array(), + ) + .unwrap() + .into_array(), + session.create_execution_ctx(), + ) + }) + .bench_refs(|(array, ctx)| array.clone().execute::(ctx)); +} + +#[divan::bench(args = [2, 4])] +fn interleave_bool_nullable(bencher: Bencher, num_branches: usize) { + let (values, array_indices, row_indices) = inputs(num_branches, true); + let session = VortexSession::empty(); + bencher + .with_inputs(|| { + ( + InterleaveArray::try_new( + values.clone(), + array_indices.clone().into_array(), + row_indices.clone().into_array(), + ) + .unwrap() + .into_array(), + session.create_execution_ctx(), + ) + }) + .bench_refs(|(array, ctx)| array.clone().execute::(ctx)); +} diff --git a/vortex-array/benches/listview_rebuild.rs b/vortex-array/benches/listview_rebuild.rs index 00781b1a534..31fdaf5e890 100644 --- a/vortex-array/benches/listview_rebuild.rs +++ b/vortex-array/benches/listview_rebuild.rs @@ -6,10 +6,11 @@ #![expect(clippy::unwrap_used)] #![expect(clippy::cast_possible_truncation)] +use std::sync::LazyLock; + use divan::Bencher; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::FixedSizeListArray; use vortex_array::arrays::ListArray; @@ -20,13 +21,19 @@ use vortex_array::arrays::VarBinViewArray; use vortex_array::arrays::listview::ListViewArrayExt; use vortex_array::arrays::listview::ListViewRebuildMode; use vortex_array::dtype::FieldNames; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; +use vortex_session::VortexSession; fn main() { divan::main(); } +/// A shared session for the `ListView` rebuild benchmarks, used to create execution contexts. +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + fn make_primitive_lv(num_lists: usize, list_size: usize, step: usize) -> ListViewArray { let element_count = step * num_lists + list_size; let elements = PrimitiveArray::from_iter(0..element_count as i32).into_array(); @@ -110,92 +117,120 @@ fn make_nested_list_lv( #[divan::bench] fn i32_small(bencher: Bencher) { let lv = make_primitive_lv(50, 32, 32); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] fn i32_small_overlapping(bencher: Bencher) { let lv = make_primitive_lv(50, 8, 1); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] fn varbinview_small(bencher: Bencher) { let lv = make_varbinview_lv(50, 32, 32); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] fn struct_small(bencher: Bencher) { let lv = make_struct_lv(50, 32, 32); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] fn i32_large(bencher: Bencher) { let lv = make_primitive_lv(50, 1_024, 1_024); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] fn varbinview_large(bencher: Bencher) { let lv = make_varbinview_lv(5, 1_024, 1_024); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] fn struct_large(bencher: Bencher) { let lv = make_struct_lv(25, 1_024, 1_024); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] @@ -214,25 +249,33 @@ fn fsl_large(bencher: Bencher) { sizes.into_array(), Validity::NonNullable, ); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } #[divan::bench] fn list_i32_large(bencher: Bencher) { let lv = make_nested_list_lv(2, 512, 2); - bencher.with_inputs(|| &lv).bench_refs(|lv| { - let rebuilt = lv.rebuild(ListViewRebuildMode::MakeZeroCopyToList).unwrap(); - rebuilt - .elements() - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - }); + bencher + .with_inputs(|| (&lv, SESSION.create_execution_ctx())) + .bench_refs(|(lv, ctx)| { + let rebuilt = lv + .rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) + .unwrap(); + rebuilt + .elements() + .clone() + .execute::(ctx) + .unwrap() + }); } diff --git a/vortex-array/benches/primitive_zip.rs b/vortex-array/benches/primitive_zip.rs new file mode 100644 index 00000000000..0359b464f13 --- /dev/null +++ b/vortex-array/benches/primitive_zip.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#![expect(clippy::unwrap_used)] +#![expect( + clippy::cast_possible_truncation, + reason = "benchmark fixtures use indices that fit in the chosen widths" +)] + +use divan::Bencher; +use vortex_array::IntoArray; +use vortex_array::LEGACY_SESSION; +use vortex_array::RecursiveCanonical; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_buffer::BufferMut; +use vortex_mask::Mask; + +fn main() { + divan::main(); +} + +// Sized so the bench stays well under a few hundred microseconds under CodSpeed's instruction-count +// simulation, which runs ~10x the local walltime; the branchless value blend is still exercised. +const LEN: usize = 16_384; + +/// Fragmented (alternating) mask: the worst case for the generic run/slice copy path this kernel +/// replaces. The branchless per-row blend is mask-shape-independent, so one shape suffices. +fn mask() -> Mask { + Mask::from_iter((0..LEN).map(|i| i.is_multiple_of(2))) +} + +#[divan::bench] +fn nonnull(bencher: Bencher) { + let if_true = nonnull_array(0).into_array(); + let if_false = nonnull_array(1_000_000).into_array(); + run(bencher, if_true, if_false); +} + +#[divan::bench] +fn nullable(bencher: Bencher) { + let if_true = nullable_array(0, 7).into_array(); + let if_false = nullable_array(1_000_000, 5).into_array(); + run(bencher, if_true, if_false); +} + +fn run(bencher: Bencher, if_true: vortex_array::ArrayRef, if_false: vortex_array::ArrayRef) { + let mask = mask(); + bencher + .with_inputs(|| { + ( + if_true.clone(), + if_false.clone(), + mask.clone().into_array(), + LEGACY_SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(t, f, m, ctx)| { + m.zip(t.clone(), f.clone()) + .unwrap() + .execute::(ctx) + .unwrap(); + }); +} + +fn nonnull_array(base: i64) -> PrimitiveArray { + let mut values = BufferMut::::with_capacity(LEN); + values.extend((0..LEN as i64).map(|i| base + i)); + PrimitiveArray::new( + values.freeze(), + vortex_array::validity::Validity::NonNullable, + ) +} + +fn nullable_array(base: i64, null_every: usize) -> PrimitiveArray { + PrimitiveArray::from_option_iter( + (0..LEN as i64).map(|i| (!(i as usize).is_multiple_of(null_every)).then_some(base + i)), + ) +} diff --git a/vortex-array/benches/scalar_at_struct.rs b/vortex-array/benches/scalar_at_struct.rs index 757d6252bb9..5e435b1000a 100644 --- a/vortex-array/benches/scalar_at_struct.rs +++ b/vortex-array/benches/scalar_at_struct.rs @@ -3,6 +3,8 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; @@ -10,12 +12,13 @@ use rand::distr::Uniform; use rand::rngs::StdRng; use vortex_array::ArrayRef; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::StructArray; use vortex_array::dtype::FieldNames; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; +use vortex_session::VortexSession; fn main() { divan::main(); @@ -24,6 +27,9 @@ fn main() { const ARRAY_SIZE: usize = 100_000; const NUM_ACCESSES: usize = 1000; +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench] fn execute_scalar_struct_simple(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(0); @@ -49,11 +55,10 @@ fn execute_scalar_struct_simple(bencher: Bencher) { .collect(); bencher - .with_inputs(|| (&struct_array, &indices)) - .bench_refs(|(array, indices)| { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + .with_inputs(|| (&struct_array, &indices, SESSION.create_execution_ctx())) + .bench_refs(|(array, indices, ctx)| { for &idx in indices.iter() { - divan::black_box(array.execute_scalar(idx, &mut ctx).unwrap()); + divan::black_box(array.execute_scalar(idx, ctx).unwrap()); } }); } @@ -87,11 +92,10 @@ fn execute_scalar_struct_wide(bencher: Bencher) { .collect(); bencher - .with_inputs(|| (&struct_array, &indices)) - .bench_refs(|(array, indices)| { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + .with_inputs(|| (&struct_array, &indices, SESSION.create_execution_ctx())) + .bench_refs(|(array, indices, ctx)| { for &idx in indices.iter() { - divan::black_box(array.execute_scalar(idx, &mut ctx).unwrap()); + divan::black_box(array.execute_scalar(idx, ctx).unwrap()); } }); } diff --git a/vortex-array/benches/scalar_subtract.rs b/vortex-array/benches/scalar_subtract.rs index bdbcdd36f3c..2eee8f3d9ff 100644 --- a/vortex-array/benches/scalar_subtract.rs +++ b/vortex-array/benches/scalar_subtract.rs @@ -3,25 +3,31 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::distr::Uniform; use rand::rngs::StdRng; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ChunkedArray; use vortex_array::arrays::ConstantArray; use vortex_array::builtins::ArrayBuiltins; use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::session::ArraySession; use vortex_buffer::Buffer; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[divan::bench] fn scalar_subtract(bencher: Bencher) { let mut rng = StdRng::seed_from_u64(0); @@ -41,7 +47,7 @@ fn scalar_subtract(bencher: Bencher) { let chunked = ChunkedArray::from_iter([data1, data2]).into_array(); bencher - .with_inputs(|| (&chunked, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&chunked, SESSION.create_execution_ctx())) .bench_refs(|(chunked, ctx)| { chunked .clone() diff --git a/vortex-array/benches/slice_dict_primitive.rs b/vortex-array/benches/slice_dict_primitive.rs index 79240bc0a0f..20a371e670e 100644 --- a/vortex-array/benches/slice_dict_primitive.rs +++ b/vortex-array/benches/slice_dict_primitive.rs @@ -8,7 +8,9 @@ use divan::Bencher; use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::arrays::DictArray; +use vortex_array::arrays::Primitive; use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::slice::SliceReduce; fn main() { divan::main(); @@ -46,6 +48,29 @@ fn slice_primitive_tight_loop(bencher: Bencher, len: usize) { }); } +#[divan::bench(args = ARRAY_LENGTHS)] +fn slice_primitive_reduce_tight_loop(bencher: Bencher, len: usize) { + let arr = build_primitive(len); + let slice_len = 64; + + let num_slices = len / slice_len; + + bencher + .with_inputs(|| (&arr, Vec::::with_capacity(num_slices))) + .bench_refs(|(arr, out)| { + out.clear(); + let mut offset = 0; + while offset + slice_len <= len { + out.push( + ::slice(arr.as_view(), offset..offset + slice_len) + .unwrap() + .unwrap(), + ); + offset += slice_len; + } + }); +} + #[divan::bench(args = ARRAY_LENGTHS)] fn slice_dict_tight_loop(bencher: Bencher, len: usize) { let dict = build_dict(len).into_array(); diff --git a/vortex-array/benches/take_fsl.rs b/vortex-array/benches/take_fsl.rs index baf2d4ede37..4764f5538be 100644 --- a/vortex-array/benches/take_fsl.rs +++ b/vortex-array/benches/take_fsl.rs @@ -10,22 +10,28 @@ #![expect(clippy::cast_possible_truncation)] #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::FixedSizeListArray; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + /// Number of lists in the source array. const NUM_LISTS: usize = 500; @@ -62,7 +68,7 @@ fn take_fsl_random(bencher: Bencher, num_indices: usize) let indices_array = indices.into_array(); bencher - .with_inputs(|| (&fsl, &indices_array, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&fsl, &indices_array, SESSION.create_execution_ctx())) .bench_refs(|(array, indices, execution_ctx)| { array .clone() @@ -88,7 +94,7 @@ fn take_fsl_nullable_random(bencher: Bencher, num_indice let indices_array = indices.into_array(); bencher - .with_inputs(|| (&fsl, &indices_array, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&fsl, &indices_array, SESSION.create_execution_ctx())) .bench_refs(|(array, indices, execution_ctx)| { array .clone() diff --git a/vortex-array/benches/take_patches.rs b/vortex-array/benches/take_patches.rs index f929cbd3b69..4d87f8b3c63 100644 --- a/vortex-array/benches/take_patches.rs +++ b/vortex-array/benches/take_patches.rs @@ -4,23 +4,29 @@ #![expect(clippy::unwrap_used)] #![expect(clippy::cast_possible_truncation)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; use vortex_array::ArrayRef; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; #[expect(deprecated)] use vortex_array::ToCanonical as _; use vortex_array::VortexSessionExecute; use vortex_array::patches::Patches; +use vortex_array::session::ArraySession; use vortex_buffer::Buffer; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + const BENCH_ARGS: &[(f64, f64)] = &[ // patches_sparsity, index_multiple (0.1, 1.0), @@ -48,7 +54,7 @@ fn take_search(bencher: Bencher, (patches_sparsity, index_multiple): (f64, f64)) ); bencher - .with_inputs(|| (&patches, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&patches, &indices, SESSION.create_execution_ctx())) .bench_refs(|(patches, indices, ctx)| { #[expect(deprecated)] let prim = indices.to_primitive(); @@ -67,7 +73,7 @@ fn take_search_chunked(bencher: Bencher, (patches_sparsity, index_multiple): (f6 ); bencher - .with_inputs(|| (&patches, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&patches, &indices, SESSION.create_execution_ctx())) .bench_refs(|(patches, indices, ctx)| { #[expect(deprecated)] let prim = indices.to_primitive(); @@ -86,7 +92,7 @@ fn take_map(bencher: Bencher, (patches_sparsity, index_multiple): (f64, f64)) { ); bencher - .with_inputs(|| (&patches, &indices, LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (&patches, &indices, SESSION.create_execution_ctx())) .bench_refs(|(patches, indices, ctx)| { #[expect(deprecated)] let prim = indices.to_primitive(); diff --git a/vortex-array/benches/take_primitive.rs b/vortex-array/benches/take_primitive.rs index 4411cf1ac79..e211e54d5f7 100644 --- a/vortex-array/benches/take_primitive.rs +++ b/vortex-array/benches/take_primitive.rs @@ -8,16 +8,19 @@ #![expect(clippy::cast_possible_truncation)] #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::distr::Uniform; use rand::prelude::*; use rand_distr::Zipf; use vortex_array::Canonical; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::DictArray; use vortex_array::arrays::PrimitiveArray; +use vortex_array::session::ArraySession; +use vortex_session::VortexSession; fn main() { divan::main(); @@ -29,6 +32,9 @@ const NUM_INDICES: &[usize] = &[1_000, 10_000, 100_000]; /// Size of the source vector / dictionary values. const VECTOR_SIZE: &[usize] = &[16, 256, 2048, 8192]; +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + // --- DictArray canonicalization benchmarks --- #[divan::bench(args = NUM_INDICES, consts = VECTOR_SIZE, sample_count = 100_000)] @@ -41,12 +47,9 @@ fn dict_canonicalize_uniform(bencher: Bencher, num_indi let dict = DictArray::try_new(codes.into_array(), values.into_array()).unwrap(); - bencher.with_inputs(|| &dict).bench_refs(|dict| { - (*dict) - .clone() - .into_array() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - }); + bencher + .with_inputs(|| (&dict, SESSION.create_execution_ctx())) + .bench_refs(|(dict, ctx)| (*dict).clone().into_array().execute::(ctx)); } #[divan::bench(args = NUM_INDICES, consts = VECTOR_SIZE, sample_count = 100_000)] @@ -63,10 +66,7 @@ fn dict_canonicalize_zipfian(bencher: Bencher, num_indi let dict = DictArray::try_new(codes.into_array(), values.into_array()).unwrap(); - bencher.with_inputs(|| &dict).bench_refs(|dict| { - (*dict) - .clone() - .into_array() - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - }); + bencher + .with_inputs(|| (&dict, SESSION.create_execution_ctx())) + .bench_refs(|(dict, ctx)| (*dict).clone().into_array().execute::(ctx)); } diff --git a/vortex-array/benches/take_struct.rs b/vortex-array/benches/take_struct.rs index 6346bb705ac..5f15894383a 100644 --- a/vortex-array/benches/take_struct.rs +++ b/vortex-array/benches/take_struct.rs @@ -3,24 +3,30 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::distr::Uniform; use rand::rngs::StdRng; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::StructArray; use vortex_array::dtype::FieldNames; +use vortex_array::session::ArraySession; use vortex_array::validity::Validity; use vortex_buffer::Buffer; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + const ARRAY_SIZE: usize = 100_000; const TAKE_SIZE: usize = 1000; @@ -54,7 +60,7 @@ fn take_struct_simple(bencher: Bencher) { ( &struct_array, &indices_array, - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, indices, ctx)| { @@ -97,7 +103,7 @@ fn take_struct_wide(bencher: Bencher, width: usize) { ( &struct_array, &indices_array, - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, indices, ctx)| { @@ -137,7 +143,7 @@ fn take_struct_sequential_indices(bencher: Bencher) { ( &struct_array, &indices_array, - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(array, indices, ctx)| { diff --git a/vortex-array/benches/to_arrow.rs b/vortex-array/benches/to_arrow.rs index 332d0b642c0..0f84feb7baa 100644 --- a/vortex-array/benches/to_arrow.rs +++ b/vortex-array/benches/to_arrow.rs @@ -4,11 +4,11 @@ #![expect(clippy::unwrap_used)] use std::sync::Arc; +use std::sync::LazyLock; use divan::Bencher; use vortex_array::ArrayRef; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::DecimalArray; use vortex_array::arrays::ListArray; @@ -25,11 +25,16 @@ use vortex_array::dtype::DecimalDType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::dtype::StructFields; +use vortex_array::session::ArraySession; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + fn schema() -> DType { let fields = StructFields::from_iter([ ( @@ -87,22 +92,17 @@ fn to_arrow_dtype(bencher: Bencher) { #[divan::bench] fn ArrowExportVTable_to_arrow_field(bencher: Bencher) { // Warm the ArrowSession - drop( - LEGACY_SESSION - .arrow() - .to_arrow_field("", &schema()) - .unwrap(), - ); + drop(SESSION.arrow().to_arrow_field("", &schema()).unwrap()); bencher .with_inputs(schema) - .bench_values(|dtype| LEGACY_SESSION.arrow().to_arrow_field("", &dtype).unwrap()) + .bench_values(|dtype| SESSION.arrow().to_arrow_field("", &dtype).unwrap()) } #[divan::bench] fn to_arrow_array(bencher: Bencher) { bencher - .with_inputs(|| (array(), LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (array(), SESSION.create_execution_ctx())) .bench_values(|(array, mut ctx)| { #[expect(deprecated, reason = "benchmarking deprecated code path")] array.execute_arrow(None, &mut ctx).unwrap() @@ -113,16 +113,16 @@ fn to_arrow_array(bencher: Bencher) { #[divan::bench] fn ArrowExportVTable_execute_arrow(bencher: Bencher) { // Warm the ArrowSession - drop(LEGACY_SESSION.arrow().execute_arrow( - array(), - None, - &mut LEGACY_SESSION.create_execution_ctx(), - )); + drop( + SESSION + .arrow() + .execute_arrow(array(), None, &mut SESSION.create_execution_ctx()), + ); bencher - .with_inputs(|| (array(), LEGACY_SESSION.create_execution_ctx())) + .with_inputs(|| (array(), SESSION.create_execution_ctx())) .bench_values(|(array, mut ctx)| { - LEGACY_SESSION + SESSION .arrow() .execute_arrow(array, None, &mut ctx) .unwrap() diff --git a/vortex-array/benches/varbinview_zip.rs b/vortex-array/benches/varbinview_zip.rs index b7283bd7c52..d18012d9914 100644 --- a/vortex-array/benches/varbinview_zip.rs +++ b/vortex-array/benches/varbinview_zip.rs @@ -3,21 +3,27 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; use vortex_array::arrays::VarBinViewArray; use vortex_array::builtins::ArrayBuiltins; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; +use vortex_array::session::ArraySession; use vortex_mask::Mask; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + /// Benchmarks zip on VarBinView arrays with a highly fragmented mask (worst case for per-slice lookup paths). #[divan::bench] fn varbinview_zip_fragmented_mask(bencher: Bencher) { @@ -32,7 +38,7 @@ fn varbinview_zip_fragmented_mask(bencher: Bencher) { if_true.clone(), if_false.clone(), mask.clone().into_array(), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(t, f, m, ctx)| { @@ -57,7 +63,7 @@ fn varbinview_zip_block_mask(bencher: Bencher) { if_true.clone(), if_false.clone(), mask.clone().into_array(), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(t, f, m, ctx)| { diff --git a/vortex-array/src/aggregate_fn/accumulator.rs b/vortex-array/src/aggregate_fn/accumulator.rs index 762d5e00fb0..c89418e67a6 100644 --- a/vortex-array/src/aggregate_fn/accumulator.rs +++ b/vortex-array/src/aggregate_fn/accumulator.rs @@ -142,7 +142,6 @@ impl DynAccumulator for Accumulator { } let session = ctx.session().clone(); - let kernels = &session.aggregate_fns().kernels; // 1. Kernel registry first: a registered `(encoding, aggregate_fn)` kernel is strictly // more specific than the vtable's `try_accumulate` short-circuit. Checking the @@ -150,13 +149,9 @@ impl DynAccumulator for Accumulator { // `Combined::try_accumulate` always returns true, so a later kernel check would be // unreachable. { - let kernels_r = kernels.read(); - let batch_id = batch.encoding_id(); - let kernel = kernels_r - .get(&(batch_id, Some(self.aggregate_fn.id()))) - .or_else(|| kernels_r.get(&(batch_id, None))) - .copied(); - drop(kernels_r); + let kernel = session + .aggregate_fns() + .find_aggregate_kernel(batch.encoding_id(), self.aggregate_fn.id()); if let Some(kernel) = kernel && let Some(result) = kernel.aggregate(&self.aggregate_fn, batch, ctx)? { @@ -187,14 +182,9 @@ impl DynAccumulator for Accumulator { break; } - let kernels_r = kernels.read(); - let batch_id = batch.encoding_id(); - let kernel = kernels_r - .get(&(batch_id, Some(self.aggregate_fn.id()))) - .or_else(|| kernels_r.get(&(batch_id, None))) - .copied(); - drop(kernels_r); - if let Some(kernel) = kernel + if let Some(kernel) = session + .aggregate_fns() + .find_aggregate_kernel(batch.encoding_id(), self.aggregate_fn.id()) && let Some(result) = kernel.aggregate(&self.aggregate_fn, &batch, ctx)? { vortex_ensure!( diff --git a/vortex-array/src/aggregate_fn/accumulator_grouped.rs b/vortex-array/src/aggregate_fn/accumulator_grouped.rs index a751a7c5749..b87c04ee204 100644 --- a/vortex-array/src/aggregate_fn/accumulator_grouped.rs +++ b/vortex-array/src/aggregate_fn/accumulator_grouped.rs @@ -11,7 +11,6 @@ use vortex_error::vortex_err; use vortex_error::vortex_panic; use vortex_mask::Mask; -use crate::AnyCanonical; use crate::ArrayRef; use crate::Canonical; use crate::Columnar; @@ -30,14 +29,152 @@ use crate::arrays::fixed_size_list::FixedSizeListArrayExt; use crate::arrays::listview::ListViewArrayExt; use crate::builders::builder_with_capacity; use crate::builtins::ArrayBuiltins; +use crate::columnar::AnyColumnar; use crate::dtype::DType; -use crate::dtype::IntegerPType; use crate::executor::max_iterations; use crate::match_each_integer_ptype; /// Reference-counted type-erased grouped accumulator. pub type GroupedAccumulatorRef = Box; +/// A batch of grouped values to aggregate. +/// +/// Each outer list value is one group, and the inner element array is shared by all groups. +/// Aggregate implementations can inspect the concrete grouped representation directly, or ask for +/// derived ranges when their algorithm is expressed in terms of `(offset, size)` pairs. +pub enum GroupedArray { + /// Groups represented as a list-view array with per-group offsets and sizes. + ListView(ListViewArray), + /// Groups represented as a fixed-size list array. + FixedSizeList(FixedSizeListArray), +} + +impl From for GroupedArray { + fn from(groups: ListViewArray) -> Self { + Self::ListView(groups) + } +} + +impl From for GroupedArray { + fn from(groups: FixedSizeListArray) -> Self { + Self::FixedSizeList(groups) + } +} + +impl GroupedArray { + /// The inner element array shared by all groups. + pub fn elements(&self) -> &ArrayRef { + match self { + Self::ListView(groups) => groups.elements(), + Self::FixedSizeList(groups) => groups.elements(), + } + } + + /// Return the `(offset, size)` ranges describing each group in `elements`. + pub fn group_ranges(&self, ctx: &mut ExecutionCtx) -> VortexResult { + match self { + Self::ListView(groups) => list_view_group_ranges(groups, ctx), + Self::FixedSizeList(groups) => Ok(fixed_size_list_group_ranges(groups)), + } + } + + /// Return the per-group validity mask. + pub fn group_validity(&self, ctx: &mut ExecutionCtx) -> VortexResult { + match self { + Self::ListView(groups) => groups.validity()?.execute_mask(groups.len(), ctx), + Self::FixedSizeList(groups) => groups.validity()?.execute_mask(groups.len(), ctx), + } + } + + /// The number of groups in this batch. + pub fn len(&self) -> usize { + match self { + Self::ListView(groups) => groups.len(), + Self::FixedSizeList(groups) => groups.len(), + } + } + + /// Returns true when this batch contains no groups. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns true when every group is valid. + pub fn all_groups_valid(&self, ctx: &mut ExecutionCtx) -> VortexResult { + Ok(self.group_validity(ctx)?.all_true()) + } + + unsafe fn with_elements_unchecked(&self, elements: ArrayRef) -> VortexResult { + Ok(match self { + Self::ListView(groups) => unsafe { + ListViewArray::new_unchecked( + elements, + groups.offsets().clone(), + groups.sizes().clone(), + groups.validity()?, + ) + } + .into(), + Self::FixedSizeList(groups) => unsafe { + FixedSizeListArray::new_unchecked( + elements, + groups.list_size(), + groups.validity()?, + groups.len(), + ) + } + .into(), + }) + } +} + +/// The physical ranges of a grouped array. +pub enum GroupRanges { + /// Explicit ranges extracted from a list-view array. + ListView { + /// The `(offset, size)` ranges. + ranges: Vec<(usize, usize)>, + }, + /// Uniform ranges derived from a fixed-size list array. + FixedSizeList { + /// The number of groups. + len: usize, + /// The number of elements in each group. + size: usize, + }, +} + +impl GroupRanges { + /// The number of groups described by these ranges. + pub fn len(&self) -> usize { + match self { + Self::ListView { ranges } => ranges.len(), + Self::FixedSizeList { len, .. } => *len, + } + } + + /// Returns true when there are no groups. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Return the `(offset, size)` range for the group at `index`. + fn range(&self, index: usize) -> (usize, usize) { + match self { + Self::ListView { ranges } => ranges[index], + Self::FixedSizeList { len, size } => { + assert!(index < *len, "range index out of bounds"); + (index * size, *size) + } + } + } + + /// Iterate over all `(offset, size)` group ranges. + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.len()).map(|index| self.range(index)) + } +} + /// An accumulator used for computing grouped aggregates. /// /// Note that the groups must be processed in order, and the accumulator does not support random @@ -128,8 +265,8 @@ impl DynGroupedAccumulator for GroupedAccumulator { Columnar::Constant(c) => c.into_array().execute::(ctx)?, }; match canonical { - Canonical::List(groups) => self.accumulate_list_view(&groups, ctx), - Canonical::FixedSizeList(groups) => self.accumulate_fixed_size_list(&groups, ctx), + Canonical::List(groups) => self.accumulate_grouped_array(groups.into(), ctx), + Canonical::FixedSizeList(groups) => self.accumulate_grouped_array(groups.into(), ctx), _ => vortex_panic!("We checked the DType above, so this should never happen"), } } @@ -155,167 +292,83 @@ impl DynGroupedAccumulator for GroupedAccumulator { } impl GroupedAccumulator { - fn accumulate_list_view( + fn accumulate_grouped_array( &mut self, - groups: &ListViewArray, + groups: GroupedArray, ctx: &mut ExecutionCtx, ) -> VortexResult<()> { let mut elements = groups.elements().clone(); - let groups_validity = groups.validity()?; let session = ctx.session().clone(); - let kernels = &session.aggregate_fns().grouped_kernels; for _ in 0..max_iterations() { - if elements.is::() { - break; + // Try a registered grouped kernel for the current element encoding. + if let Some(kernel) = session + .aggregate_fns() + .find_grouped_encoding_kernel(elements.encoding_id(), self.aggregate_fn.id()) + { + // SAFETY: we assume that elements execution is safe + let kernel_groups = unsafe { groups.with_elements_unchecked(elements.clone())? }; + if let Some(result) = + kernel.grouped_aggregate(&self.aggregate_fn, &kernel_groups, ctx)? + { + return self.push_result(result); + } } - let kernels_r = kernels.read(); - if let Some(result) = kernels_r - .get(&(elements.encoding_id(), Some(self.aggregate_fn.id()))) - .or_else(|| kernels_r.get(&(elements.encoding_id(), None))) - .and_then(|kernel| { - // SAFETY: we assume that elements execution is safe - let groups = unsafe { - ListViewArray::new_unchecked( - elements.clone(), - groups.offsets().clone(), - groups.sizes().clone(), - groups_validity.clone(), - ) - }; - kernel - .grouped_aggregate(&self.aggregate_fn, &groups) - .transpose() - }) - .transpose()? + // Try a grouped kernel for the current aggregate regardless of element encoding. + if let Some(kernel) = session + .aggregate_fns() + .find_grouped_kernel(self.aggregate_fn.id()) { - return self.push_result(result); + // SAFETY: we preserve the grouped shape and validity while replacing the + // elements with another representation of the same logical array. + let kernel_groups = unsafe { groups.with_elements_unchecked(elements.clone())? }; + if let Some(result) = + kernel.grouped_aggregate(&self.aggregate_fn, &kernel_groups, ctx)? + { + return self.push_result(result); + } + } + + if elements.is::() { + break; } // Execute one step and try again elements = elements.execute(ctx)?; } - // Otherwise, we iterate the offsets and sizes and accumulate each group one by one. let elements = elements.execute::(ctx)?.into_array(); - let offsets = groups.offsets(); - let sizes = groups.sizes().cast(offsets.dtype().clone())?; - let validity = groups_validity.execute_mask(offsets.len(), ctx)?; - - match_each_integer_ptype!(offsets.dtype().as_ptype(), |O| { - let offsets = offsets.clone().execute::>(ctx)?; - let sizes = sizes.execute::>(ctx)?; - self.accumulate_list_view_typed( - &elements, - offsets.as_ref(), - sizes.as_ref(), - &validity, - ctx, - ) - }) - } - - fn accumulate_list_view_typed( - &mut self, - elements: &ArrayRef, - offsets: &[O], - sizes: &[O], - validity: &Mask, - ctx: &mut ExecutionCtx, - ) -> VortexResult<()> { - let mut accumulator = Accumulator::try_new( - self.vtable.clone(), - self.options.clone(), - self.dtype.clone(), - )?; - let mut states = builder_with_capacity(&self.partial_dtype, offsets.len()); - - for (offset, size) in offsets.iter().zip(sizes.iter()) { - let offset = offset.to_usize().vortex_expect("Offset value is not usize"); - let size = size.to_usize().vortex_expect("Size value is not usize"); - - if validity.value(offset) { - let group = elements.slice(offset..offset + size)?; - accumulator.accumulate(&group, ctx)?; - states.append_scalar(&accumulator.flush()?)?; - } else { - states.append_null() - } - } + // SAFETY: we preserve the grouped shape and validity while replacing the elements with an + // executed form of the same logical array. + let grouped = unsafe { groups.with_elements_unchecked(elements)? }; - self.push_result(states.finish()) + // Otherwise, we iterate the offsets and sizes and accumulate each group one by one. + self.accumulate_grouped_fallback(&grouped, ctx) } - fn accumulate_fixed_size_list( + fn accumulate_grouped_fallback( &mut self, - groups: &FixedSizeListArray, + grouped: &GroupedArray, ctx: &mut ExecutionCtx, ) -> VortexResult<()> { - let mut elements = groups.elements().clone(); - let groups_validity = groups.validity()?; - let session = ctx.session().clone(); - let kernels = &session.aggregate_fns().grouped_kernels; - - for _ in 0..64 { - if elements.is::() { - break; - } - - let kernels_r = kernels.read(); - if let Some(result) = kernels_r - .get(&(elements.encoding_id(), Some(self.aggregate_fn.id()))) - .or_else(|| kernels_r.get(&(elements.encoding_id(), None))) - .and_then(|kernel| { - // SAFETY: we assume that elements execution is safe - let groups = unsafe { - FixedSizeListArray::new_unchecked( - elements.clone(), - groups.list_size(), - groups_validity.clone(), - groups.len(), - ) - }; - - kernel - .grouped_aggregate_fixed_size(&self.aggregate_fn, &groups) - .transpose() - }) - .transpose()? - { - return self.push_result(result); - } - - // Execute one step and try again - elements = elements.execute(ctx)?; - } - - // Otherwise, we iterate the offsets and sizes and accumulate each group one by one. - let elements = elements.execute::(ctx)?.into_array(); - let validity = groups_validity.execute_mask(groups.len(), ctx)?; - let mut accumulator = Accumulator::try_new( self.vtable.clone(), self.options.clone(), self.dtype.clone(), )?; - let mut states = builder_with_capacity(&self.partial_dtype, groups.len()); + let mut states = builder_with_capacity(&self.partial_dtype, grouped.len()); + let group_ranges = grouped.group_ranges(ctx)?; + let group_validity = grouped.group_validity(ctx)?; - let mut offset = 0; - let size = groups - .list_size() - .to_usize() - .vortex_expect("List size is not usize"); - - for i in 0..groups.len() { - if validity.value(i) { - let group = elements.slice(offset..offset + size)?; + for ((offset, size), valid) in group_ranges.iter().zip(group_validity.iter()) { + if valid { + let group = grouped.elements().slice(offset..offset + size)?; accumulator.accumulate(&group, ctx)?; states.append_scalar(&accumulator.flush()?)?; } else { states.append_null() } - offset += size; } self.push_result(states.finish()) @@ -332,3 +385,35 @@ impl GroupedAccumulator { Ok(()) } } +fn list_view_group_ranges( + groups: &ListViewArray, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let offsets = groups.offsets(); + let sizes = groups.sizes().cast(offsets.dtype().clone())?; + + let ranges = match_each_integer_ptype!(offsets.dtype().as_ptype(), |O| { + let offsets = offsets.clone().execute::>(ctx)?; + let sizes = sizes.execute::>(ctx)?; + offsets + .as_ref() + .iter() + .zip(sizes.as_ref().iter()) + .map(|(offset, size)| { + ( + offset.to_usize().vortex_expect("Offset value is not usize"), + size.to_usize().vortex_expect("Size value is not usize"), + ) + }) + .collect::>() + }); + + Ok(GroupRanges::ListView { ranges }) +} + +fn fixed_size_list_group_ranges(groups: &FixedSizeListArray) -> GroupRanges { + GroupRanges::FixedSizeList { + len: groups.len(), + size: groups.list_size() as usize, + } +} diff --git a/vortex-array/src/aggregate_fn/fns/all_non_distinct/bool.rs b/vortex-array/src/aggregate_fn/fns/all_non_distinct/bool.rs index c06d335e458..f201653b815 100644 --- a/vortex-array/src/aggregate_fn/fns/all_non_distinct/bool.rs +++ b/vortex-array/src/aggregate_fn/fns/all_non_distinct/bool.rs @@ -10,5 +10,5 @@ where L: BoolArrayExt, R: BoolArrayExt, { - Ok(lhs.to_bit_buffer() == rhs.to_bit_buffer()) + Ok(lhs.bit_buffer_view() == rhs.bit_buffer_view()) } diff --git a/vortex-array/src/aggregate_fn/fns/all_non_distinct/filter.rs b/vortex-array/src/aggregate_fn/fns/all_non_distinct/filter.rs index 9ad4ea2cc30..b9aacd56163 100644 --- a/vortex-array/src/aggregate_fn/fns/all_non_distinct/filter.rs +++ b/vortex-array/src/aggregate_fn/fns/all_non_distinct/filter.rs @@ -14,7 +14,7 @@ pub(super) fn shared_validity_mask( ) -> VortexResult> { let lhs_validity = lhs.validity()?; let rhs_validity = rhs.validity()?; - if lhs_validity.no_nulls() && rhs_validity.no_nulls() { + if lhs_validity.definitely_no_nulls() && rhs_validity.definitely_no_nulls() { return Ok(Some(Mask::new_true(lhs.len()))); } @@ -33,7 +33,7 @@ pub(super) fn filter_valid_rows_if_needed( ctx: &mut ExecutionCtx, ) -> VortexResult> { let validity = lhs.validity()?; - if validity.no_nulls() { + if validity.definitely_no_nulls() { return Ok(None); } diff --git a/vortex-array/src/aggregate_fn/fns/all_non_distinct/list.rs b/vortex-array/src/aggregate_fn/fns/all_non_distinct/list.rs index 9aa5e93d08c..2f4e3e52d91 100644 --- a/vortex-array/src/aggregate_fn/fns/all_non_distinct/list.rs +++ b/vortex-array/src/aggregate_fn/fns/all_non_distinct/list.rs @@ -28,8 +28,8 @@ pub(super) fn check_list_identical( return check_zero_copy_list_identical(lhs, rhs, ctx); } - let lhs = list_from_list_view(lhs.clone())?; - let rhs = list_from_list_view(rhs.clone())?; + let lhs = list_from_list_view(lhs.clone(), ctx)?; + let rhs = list_from_list_view(rhs.clone(), ctx)?; if !check_list_offsets_identical(&lhs, &rhs)? { return Ok(false); diff --git a/vortex-array/src/aggregate_fn/fns/count/grouped.rs b/vortex-array/src/aggregate_fn/fns/count/grouped.rs new file mode 100644 index 00000000000..fb94489dde0 --- /dev/null +++ b/vortex-array/src/aggregate_fn/fns/count/grouped.rs @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_buffer::Buffer; +use vortex_error::VortexResult; +use vortex_mask::Mask; + +use super::Count; +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::aggregate_fn::AggregateFnRef; +use crate::aggregate_fn::GroupRanges; +use crate::aggregate_fn::GroupedArray; +use crate::aggregate_fn::kernels::DynGroupedAggregateKernel; +use crate::arrays::PrimitiveArray; +use crate::validity::Validity; + +/// Encoding-independent grouped [`Count`] kernel. +#[derive(Debug)] +pub(crate) struct CountGroupedKernel; + +impl DynGroupedAggregateKernel for CountGroupedKernel { + fn grouped_aggregate( + &self, + aggregate_fn: &AggregateFnRef, + groups: &GroupedArray, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { + if !aggregate_fn.is::() { + return Ok(None); + } + try_grouped_count(groups, ctx) + } +} + +/// Count each valid group from the element validity mask. +/// +/// The [`Count`] partial dtype is non-nullable `U64`, so a null outer group cannot be represented +/// as a partial state. If any outer group is invalid, this returns `Ok(None)` and lets the caller +/// use the existing fallback behavior. +pub(super) fn try_grouped_count( + groups: &GroupedArray, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + if !groups.all_groups_valid(ctx)? { + return Ok(None); + } + let group_ranges = groups.group_ranges(ctx)?; + + Ok(Some(grouped_count(groups.elements(), &group_ranges, ctx)?)) +} + +/// Count the valid elements of each group described by `group_ranges` (element `(offset, size)` +/// pairs) into a non-nullable `U64` array, one entry per group. +fn grouped_count( + elements: &ArrayRef, + group_ranges: &GroupRanges, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let elem_mask = elements.validity()?.execute_mask(elements.len(), ctx)?; + + let counts: Buffer = if elem_mask.all_true() { + group_ranges.iter().map(|(_, size)| size as u64).collect() + } else { + group_ranges + .iter() + .map(|(offset, size)| valid_count(&elem_mask, offset, size) as u64) + .collect() + }; + + Ok(PrimitiveArray::new(counts, Validity::NonNullable).into_array()) +} + +/// Number of valid elements in the `[offset, offset + size)` range of the element mask. +fn valid_count(elem_mask: &Mask, offset: usize, size: usize) -> usize { + elem_mask.slice(offset..offset + size).true_count() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::cast_possible_truncation)] + + use vortex_buffer::Buffer; + use vortex_buffer::buffer; + use vortex_error::VortexResult; + + use crate::ArrayRef; + use crate::IntoArray; + use crate::LEGACY_SESSION; + use crate::VortexSessionExecute; + use crate::aggregate_fn::DynGroupedAccumulator; + use crate::aggregate_fn::EmptyOptions; + use crate::aggregate_fn::GroupedAccumulator; + use crate::aggregate_fn::fns::count::Count; + use crate::arrays::FixedSizeListArray; + use crate::arrays::ListViewArray; + use crate::arrays::PrimitiveArray; + use crate::arrays::VarBinViewArray; + use crate::assert_arrays_eq; + use crate::dtype::DType; + use crate::dtype::Nullability::NonNullable; + use crate::dtype::Nullability::Nullable; + use crate::dtype::PType; + use crate::validity::Validity; + + /// Run a grouped count through the accumulator. + fn grouped_count_actual(groups: &ArrayRef, elem_dtype: &DType) -> VortexResult { + let mut acc = GroupedAccumulator::try_new(Count, EmptyOptions, elem_dtype.clone())?; + acc.accumulate_list(groups, &mut LEGACY_SESSION.create_execution_ctx())?; + acc.finish() + } + + /// Reference valid-counts (non-nullable `U64`), one per group. + fn grouped_count_reference( + elements: &ArrayRef, + ranges: &[(usize, usize)], + ) -> VortexResult { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let counts: Buffer = ranges + .iter() + .map(|&(offset, size)| { + Ok(elements + .slice(offset..offset + size)? + .valid_count(&mut ctx)? as u64) + }) + .collect::>()?; + Ok(PrimitiveArray::new(counts, Validity::NonNullable).into_array()) + } + + fn listview(elements: ArrayRef, ranges: &[(usize, usize)]) -> VortexResult { + let offsets = PrimitiveArray::from_iter(ranges.iter().map(|&(o, _)| o as i32)); + let sizes = PrimitiveArray::from_iter(ranges.iter().map(|&(_, s)| s as i32)); + Ok(ListViewArray::try_new( + elements, + offsets.into_array(), + sizes.into_array(), + Validity::NonNullable, + )? + .into_array()) + } + + #[test] + fn listview_counts_all_valid() -> VortexResult<()> { + let elements = + PrimitiveArray::new(buffer![1i32, 2, 3, 4, 5, 6], Validity::NonNullable).into_array(); + let elem_dtype = DType::Primitive(PType::I32, NonNullable); + let ranges = [(0, 2), (2, 1), (3, 3), (6, 0)]; + + let groups = listview(elements.clone(), &ranges)?; + let actual = grouped_count_actual(&groups, &elem_dtype)?; + let expected = grouped_count_reference(&elements, &ranges)?; + + let direct = + PrimitiveArray::new(buffer![2u64, 1, 3, 0], Validity::NonNullable).into_array(); + assert_arrays_eq!(&actual, &direct); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } + + #[test] + fn listview_counts_with_nulls() -> VortexResult<()> { + let elements = + PrimitiveArray::from_option_iter([Some(1i32), None, Some(3), None, None, Some(9)]) + .into_array(); + let elem_dtype = DType::Primitive(PType::I32, Nullable); + let ranges = [(0, 3), (3, 2), (5, 1)]; + + let groups = listview(elements.clone(), &ranges)?; + let actual = grouped_count_actual(&groups, &elem_dtype)?; + let expected = grouped_count_reference(&elements, &ranges)?; + + // Group 0: {1, null, 3} -> 2. Group 1: {null, null} -> 0. Group 2: {9} -> 1. + let direct = PrimitiveArray::new(buffer![2u64, 0, 1], Validity::NonNullable).into_array(); + assert_arrays_eq!(&actual, &direct); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } + + #[test] + fn listview_counts_varbinview_with_nulls() -> VortexResult<()> { + let elements = VarBinViewArray::from_iter_nullable_str([ + Some("a"), + None, + Some("bbb"), + None, + Some("cc"), + ]) + .into_array(); + let elem_dtype = elements.dtype().clone(); + let ranges = [(0, 2), (2, 2), (4, 1)]; + + let groups = listview(elements.clone(), &ranges)?; + let actual = grouped_count_actual(&groups, &elem_dtype)?; + let expected = grouped_count_reference(&elements, &ranges)?; + + let direct = PrimitiveArray::new(buffer![1u64, 1, 1], Validity::NonNullable).into_array(); + assert_arrays_eq!(&actual, &direct); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } + + #[test] + fn fixed_size_counts_with_nulls() -> VortexResult<()> { + let elements = + PrimitiveArray::from_option_iter([Some(1i32), None, Some(3), Some(4)]).into_array(); + let elem_dtype = DType::Primitive(PType::I32, Nullable); + let groups = + FixedSizeListArray::try_new(elements, 2, Validity::NonNullable, 2)?.into_array(); + + let actual = grouped_count_actual(&groups, &elem_dtype)?; + let direct = PrimitiveArray::new(buffer![1u64, 2], Validity::NonNullable).into_array(); + assert_arrays_eq!(&actual, &direct); + Ok(()) + } +} diff --git a/vortex-array/src/aggregate_fn/fns/count/mod.rs b/vortex-array/src/aggregate_fn/fns/count/mod.rs index e25c42e0845..1fe984fb099 100644 --- a/vortex-array/src/aggregate_fn/fns/count/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/count/mod.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +mod grouped; +pub(crate) use grouped::CountGroupedKernel; use vortex_error::VortexExpect; use vortex_error::VortexResult; diff --git a/vortex-array/src/aggregate_fn/fns/is_constant/bool.rs b/vortex-array/src/aggregate_fn/fns/is_constant/bool.rs index c63025ccd7c..967f19058b6 100644 --- a/vortex-array/src/aggregate_fn/fns/is_constant/bool.rs +++ b/vortex-array/src/aggregate_fn/fns/is_constant/bool.rs @@ -5,6 +5,6 @@ use crate::arrays::BoolArray; use crate::arrays::bool::BoolArrayExt; pub(super) fn check_bool_constant(array: &BoolArray) -> bool { - let true_count = array.to_bit_buffer().true_count(); + let true_count = array.bit_buffer_view().true_count(); true_count == array.len() || true_count == 0 } diff --git a/vortex-array/src/aggregate_fn/fns/is_sorted/bool.rs b/vortex-array/src/aggregate_fn/fns/is_sorted/bool.rs index afd18f15902..69c0aaf6692 100644 --- a/vortex-array/src/aggregate_fn/fns/is_sorted/bool.rs +++ b/vortex-array/src/aggregate_fn/fns/is_sorted/bool.rs @@ -21,7 +21,7 @@ pub(super) fn check_bool_sorted( { Mask::AllFalse(_) => Ok(!strict), Mask::AllTrue(_) => { - let values = array.to_bit_buffer(); + let values = array.bit_buffer_view(); Ok(if strict { values.iter().is_strict_sorted() } else { @@ -31,7 +31,7 @@ pub(super) fn check_bool_sorted( Mask::Values(mask_values) => { if strict { let validity_buffer = mask_values.bit_buffer(); - let values = array.to_bit_buffer(); + let values = array.bit_buffer_view(); Ok(validity_buffer .iter() .zip(values.iter()) @@ -39,7 +39,7 @@ pub(super) fn check_bool_sorted( .is_strict_sorted()) } else { let set_indices = mask_values.bit_buffer().set_indices(); - let values = array.to_bit_buffer(); + let values = array.bit_buffer_view(); let values_iter = set_indices.map(|idx| // Safety: // All idxs are in-bounds for the array. diff --git a/vortex-array/src/aggregate_fn/fns/mean/mod.rs b/vortex-array/src/aggregate_fn/fns/mean/mod.rs index f7d471023ee..17fb2616d44 100644 --- a/vortex-array/src/aggregate_fn/fns/mean/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/mean/mod.rs @@ -104,7 +104,9 @@ impl BinaryCombined for Mean { let sum = sum_cast.as_primitive().typed_value::(); let count = count_cast.as_primitive().typed_value::(); let value = match (sum, count) { - (None, _) | (_, None) | (_, Some(0.0)) => return Ok(Scalar::null(target)), // Sum overflowed + (None, _) | (_, None) => return Ok(Scalar::null(target)), // Sum overflowed + // A count of zero yields 0/0 = NaN, matching the array `finalize` path: nulls are + // skipped during accumulation, so an all-null input is an empty mean, not null. (Some(s), Some(c)) => s / c, }; Ok(Scalar::primitive(value, Nullability::Nullable)) @@ -230,11 +232,11 @@ mod tests { } #[test] - fn mean_all_null_returns_null() -> VortexResult<()> { + fn mean_all_null_returns_nan() -> VortexResult<()> { let array = PrimitiveArray::from_option_iter::([None, None, None]).into_array(); let mut ctx = LEGACY_SESSION.create_execution_ctx(); let result = mean(&array, &mut ctx)?; - assert_eq!(result.as_primitive().as_::(), None); + assert!(result.as_primitive().as_::().is_some_and(f64::is_nan)); Ok(()) } diff --git a/vortex-array/src/aggregate_fn/fns/min_max/bool.rs b/vortex-array/src/aggregate_fn/fns/min_max/bool.rs index 54501409fe5..adca6453862 100644 --- a/vortex-array/src/aggregate_fn/fns/min_max/bool.rs +++ b/vortex-array/src/aggregate_fn/fns/min_max/bool.rs @@ -25,7 +25,7 @@ pub(super) fn accumulate_bool( .execute_mask(array.as_ref().len(), ctx)?; let (true_count, valid_count) = match mask.bit_buffer() { AllOr::None => return Ok(()), - AllOr::All => (array.to_bit_buffer().true_count(), array.as_ref().len()), + AllOr::All => (array.bit_buffer_view().true_count(), array.as_ref().len()), AllOr::Some(validity) => ( array.to_bit_buffer().bitand(validity).true_count(), validity.true_count(), diff --git a/vortex-array/src/aggregate_fn/fns/min_max/mod.rs b/vortex-array/src/aggregate_fn/fns/min_max/mod.rs index f91ae351392..540b5608e28 100644 --- a/vortex-array/src/aggregate_fn/fns/min_max/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/min_max/mod.rs @@ -61,8 +61,8 @@ pub fn min_max(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult DType { ) } +fn minmax_supported_dtype(input_dtype: &DType) -> bool { + match input_dtype { + DType::Bool(_) + | DType::Primitive(..) + | DType::Decimal(..) + | DType::Utf8(..) + | DType::Binary(..) + | DType::Extension(..) => true, + DType::List(element_dtype, _) => minmax_supported_dtype(element_dtype), + DType::FixedSizeList(element_dtype, ..) => minmax_supported_dtype(element_dtype), + _ => false, + } +} + +/// Returns whether [`min_max`] can currently compute extrema for this logical dtype. +/// +/// This is intentionally narrower than [`minmax_supported_dtype`]. List and fixed-size-list +/// extrema have a defined output dtype for aggregate expression lowering, but the accumulator does +/// not yet implement lexicographic list comparison. +fn minmax_compute_supported_dtype(input_dtype: &DType) -> bool { + matches!( + input_dtype, + DType::Bool(_) + | DType::Primitive(..) + | DType::Decimal(..) + | DType::Utf8(..) + | DType::Binary(..) + | DType::Extension(..) + ) +} + impl AggregateFnVTable for MinMax { type Options = EmptyOptions; type Partial = MinMaxPartial; @@ -175,15 +206,7 @@ impl AggregateFnVTable for MinMax { } fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option { - match input_dtype { - DType::Bool(_) - | DType::Primitive(..) - | DType::Decimal(..) - | DType::Utf8(..) - | DType::Binary(..) - | DType::Extension(..) => Some(make_minmax_dtype(input_dtype)), - _ => None, - } + minmax_supported_dtype(input_dtype).then(|| make_minmax_dtype(input_dtype)) } fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option { @@ -278,6 +301,8 @@ impl AggregateFnVTable for MinMax { #[cfg(test)] mod tests { + use std::sync::Arc; + use vortex_buffer::BitBuffer; use vortex_buffer::buffer; use vortex_error::VortexExpect; @@ -298,6 +323,8 @@ mod tests { use crate::arrays::ChunkedArray; use crate::arrays::ConstantArray; use crate::arrays::DecimalArray; + use crate::arrays::FixedSizeListArray; + use crate::arrays::ListArray; use crate::arrays::NullArray; use crate::arrays::PrimitiveArray; use crate::arrays::VarBinArray; @@ -570,6 +597,47 @@ mod tests { Ok(()) } + #[test] + fn list_and_fixed_size_list_return_dtype() { + let element_dtype = DType::Primitive(PType::I32, Nullability::Nullable); + let list_dtype = DType::List(Arc::new(element_dtype.clone()), Nullability::Nullable); + let fixed_size_list_dtype = + DType::FixedSizeList(Arc::new(element_dtype), 1, Nullability::Nullable); + + assert_eq!( + MinMax.return_dtype(&EmptyOptions, &list_dtype), + Some(make_minmax_dtype(&list_dtype)) + ); + assert_eq!( + MinMax.return_dtype(&EmptyOptions, &fixed_size_list_dtype), + Some(make_minmax_dtype(&fixed_size_list_dtype)) + ); + } + + #[test] + fn list_and_fixed_size_list_min_max_returns_none() -> VortexResult<()> { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + + let list_array = ListArray::try_new( + buffer![1i32, 2, 3].into_array(), + buffer![0u32, 2, 3].into_array(), + Validity::NonNullable, + )? + .into_array(); + assert_eq!(min_max(&list_array, &mut ctx)?, None); + + let fixed_size_list_array = FixedSizeListArray::try_new( + buffer![1i32, 2, 3, 4].into_array(), + 2, + Validity::NonNullable, + 2, + )? + .into_array(); + assert_eq!(min_max(&fixed_size_list_array, &mut ctx)?, None); + + Ok(()) + } + use crate::dtype::half::f16; #[test] diff --git a/vortex-array/src/aggregate_fn/fns/sum/bool.rs b/vortex-array/src/aggregate_fn/fns/sum/bool.rs index a32f3ba72f7..7728d3f64af 100644 --- a/vortex-array/src/aggregate_fn/fns/sum/bool.rs +++ b/vortex-array/src/aggregate_fn/fns/sum/bool.rs @@ -25,7 +25,7 @@ pub(super) fn accumulate_bool( let mask = b.as_ref().validity()?.execute_mask(b.as_ref().len(), ctx)?; let true_count = match mask.bit_buffer() { AllOr::None => return Ok(false), - AllOr::All => b.to_bit_buffer().true_count() as u64, + AllOr::All => b.bit_buffer_view().true_count() as u64, AllOr::Some(validity) => b.to_bit_buffer().bitand(validity).true_count() as u64, }; diff --git a/vortex-array/src/aggregate_fn/fns/sum/grouped.rs b/vortex-array/src/aggregate_fn/fns/sum/grouped.rs new file mode 100644 index 00000000000..6f00cce7fdb --- /dev/null +++ b/vortex-array/src/aggregate_fn/fns/sum/grouped.rs @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_error::VortexResult; +use vortex_mask::AllOr; +use vortex_mask::Mask; + +use super::Sum; +use super::primitive::sum_float_all; +use super::primitive::sum_signed_all; +use super::primitive::sum_unsigned_all; +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::aggregate_fn::AggregateFnRef; +use crate::aggregate_fn::GroupRanges; +use crate::aggregate_fn::GroupedArray; +use crate::aggregate_fn::kernels::DynGroupedAggregateKernel; +use crate::arrays::Primitive; +use crate::arrays::PrimitiveArray; +use crate::dtype::NativePType; +use crate::match_each_native_ptype; + +/// Encoding-specific grouped [`Sum`] kernel for primitive element arrays. +#[derive(Debug)] +pub(crate) struct PrimitiveGroupedSumEncodingKernel; + +impl DynGroupedAggregateKernel for PrimitiveGroupedSumEncodingKernel { + fn grouped_aggregate( + &self, + aggregate_fn: &AggregateFnRef, + groups: &GroupedArray, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { + if !aggregate_fn.is::() { + return Ok(None); + } + try_grouped_sum(groups, ctx) + } +} + +/// Grouped [`Sum`] implementation for canonical primitive elements. +/// +/// Reuses the scalar primitive-sum reductions ([`sum_unsigned_all`]/[`sum_signed_all`]/ +/// [`sum_float_all`]) so the per-group semantics match scalar `sum` exactly (overflow saturates to +/// a null sum, NaNs are skipped). The element validity mask is materialized once and sliced per +/// group, rather than the per-group accumulator setup of the generic fallback path. +pub(super) fn try_grouped_sum( + groups: &GroupedArray, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + if !groups.elements().is::() { + return Ok(None); + } + let elements = groups.elements().clone().downcast::(); + let group_ranges = groups.group_ranges(ctx)?; + let group_validity = groups.group_validity(ctx)?; + + Ok(Some(grouped_sum( + &elements, + &group_ranges, + &group_validity, + ctx, + )?)) +} + +/// Sum each group described by `group_ranges` (element `(offset, size)` pairs), one sum per group. +fn grouped_sum( + elements: &PrimitiveArray, + group_ranges: &GroupRanges, + group_validity: &Mask, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let elem_mask = elements + .as_ref() + .validity()? + .execute_mask(elements.as_ref().len(), ctx)?; + let all_valid = matches!(elem_mask.slices(), AllOr::All); + + let result = match_each_native_ptype!(elements.ptype(), + unsigned: |T| { + let values = elements.as_slice::(); + collect_sums::(values, group_ranges, group_validity, &elem_mask, all_valid, + sum_unsigned_all) + }, + signed: |T| { + let values = elements.as_slice::(); + collect_sums::(values, group_ranges, group_validity, &elem_mask, all_valid, + sum_signed_all) + }, + floating: |T| { + let values = elements.as_slice::(); + collect_sums::(values, group_ranges, group_validity, &elem_mask, all_valid, + |acc, slice| { sum_float_all(acc, slice); false }) + } + ); + + Ok(result.into_array()) +} + +/// Reduce each group's element slice into a nullable sum. A group is null when the group +/// itself is invalid, or when summing it overflows (`sum_run` returns `true`). +fn collect_sums( + values: &[T], + group_ranges: &GroupRanges, + group_validity: &Mask, + elem_mask: &Mask, + all_valid: bool, + sum_run: impl Fn(&mut A, &[T]) -> bool, +) -> PrimitiveArray { + let sums = group_ranges.iter().enumerate().map(|(i, (offset, size))| { + if !group_validity.value(i) { + return None; + } + let mut acc = A::default(); + let overflow = if all_valid { + sum_run(&mut acc, &values[offset..offset + size]) + } else { + sum_masked_group(&mut acc, values, offset, size, elem_mask, &sum_run) + }; + (!overflow).then_some(acc) + }); + PrimitiveArray::from_option_iter(sums) +} + +/// Sum the valid elements of a single group, using the contiguous valid runs of the element mask +/// intersected with the group's `[offset, offset + size)` range. +fn sum_masked_group( + acc: &mut A, + values: &[T], + offset: usize, + size: usize, + elem_mask: &Mask, + sum_run: &impl Fn(&mut A, &[T]) -> bool, +) -> bool { + match elem_mask.slice(offset..offset + size).slices() { + AllOr::All => sum_run(acc, &values[offset..offset + size]), + AllOr::None => false, + AllOr::Some(runs) => { + for &(start, end) in runs { + if sum_run(acc, &values[offset + start..offset + end]) { + return true; + } + } + false + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::cast_possible_truncation)] + + use vortex_buffer::buffer; + use vortex_error::VortexResult; + + use crate::ArrayRef; + use crate::IntoArray; + use crate::LEGACY_SESSION; + use crate::VortexSessionExecute; + use crate::aggregate_fn::DynGroupedAccumulator; + use crate::aggregate_fn::EmptyOptions; + use crate::aggregate_fn::GroupedAccumulator; + use crate::aggregate_fn::fns::sum::Sum; + use crate::aggregate_fn::fns::sum::sum; + use crate::arrays::FixedSizeListArray; + use crate::arrays::ListViewArray; + use crate::arrays::PrimitiveArray; + use crate::assert_arrays_eq; + use crate::builders::builder_with_capacity; + use crate::dtype::DType; + use crate::dtype::Nullability::NonNullable; + use crate::dtype::Nullability::Nullable; + use crate::dtype::PType; + use crate::validity::Validity; + + /// Run a grouped sum through the accumulator. + fn grouped_sum_actual(groups: &ArrayRef, elem_dtype: &DType) -> VortexResult { + let mut acc = GroupedAccumulator::try_new(Sum, EmptyOptions, elem_dtype.clone())?; + acc.accumulate_list(groups, &mut LEGACY_SESSION.create_execution_ctx())?; + acc.finish() + } + + /// Reference sums computed exactly like the generic slow path: per-group scalar [`sum`] for + /// valid groups, a null sum for invalid groups. + fn grouped_sum_reference( + elements: &ArrayRef, + ranges: &[(usize, usize)], + group_valid: &[bool], + elem_dtype: &DType, + ) -> VortexResult { + use crate::aggregate_fn::AggregateFnVTable; + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let sum_dtype = Sum + .partial_dtype(&EmptyOptions, elem_dtype) + .expect("sum partial dtype"); + let mut builder = builder_with_capacity(&sum_dtype, ranges.len()); + for (i, &(offset, size)) in ranges.iter().enumerate() { + if group_valid[i] { + let slice = elements.slice(offset..offset + size)?; + builder.append_scalar(&sum(&slice, &mut ctx)?)?; + } else { + builder.append_null(); + } + } + Ok(builder.finish()) + } + + fn offsets_sizes(ranges: &[(usize, usize)]) -> (ArrayRef, ArrayRef) { + let offsets = PrimitiveArray::from_iter(ranges.iter().map(|&(o, _)| o as i32)); + let sizes = PrimitiveArray::from_iter(ranges.iter().map(|&(_, s)| s as i32)); + (offsets.into_array(), sizes.into_array()) + } + + fn listview( + elements: ArrayRef, + ranges: &[(usize, usize)], + group_valid: &[bool], + ) -> VortexResult { + let (offsets, sizes) = offsets_sizes(ranges); + let validity = if group_valid.iter().all(|&v| v) { + Validity::NonNullable + } else { + Validity::from_iter(group_valid.iter().copied()) + }; + Ok(ListViewArray::try_new(elements, offsets, sizes, validity)?.into_array()) + } + + #[test] + fn listview_matches_reference_unsigned() -> VortexResult<()> { + let elements = + PrimitiveArray::new(buffer![1u32, 2, 3, 4, 5, 6], Validity::NonNullable).into_array(); + let elem_dtype = DType::Primitive(PType::U32, NonNullable); + let ranges = [(0, 2), (2, 1), (3, 3)]; + let valid = [true, true, true]; + + let groups = listview(elements.clone(), &ranges, &valid)?; + let actual = grouped_sum_actual(&groups, &elem_dtype)?; + let expected = grouped_sum_reference(&elements, &ranges, &valid, &elem_dtype)?; + + // Unsigned input sums to U64. + let direct = PrimitiveArray::from_option_iter([Some(3u64), Some(3u64), Some(15u64)]); + assert_arrays_eq!(&actual, &direct.into_array()); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } + + #[test] + fn listview_out_of_order_offsets_with_null_group() -> VortexResult<()> { + // Offsets are not in group order and a group is null: the group validity must be indexed by + // group index, not by element offset. + let elements = + PrimitiveArray::new(buffer![10i32, 20, 30, 40, 50, 60], Validity::NonNullable) + .into_array(); + let elem_dtype = DType::Primitive(PType::I32, NonNullable); + let ranges = [(4, 2), (0, 2), (2, 2)]; + let valid = [true, false, true]; + + let groups = listview(elements.clone(), &ranges, &valid)?; + let actual = grouped_sum_actual(&groups, &elem_dtype)?; + let expected = grouped_sum_reference(&elements, &ranges, &valid, &elem_dtype)?; + + let direct = PrimitiveArray::from_option_iter([Some(110i64), None, Some(70i64)]); + assert_arrays_eq!(&actual, &direct.into_array()); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } + + #[test] + fn listview_interior_and_full_nulls() -> VortexResult<()> { + // Group 1 has an interior null, group 2 is entirely null, group 3 is empty. + let elements = + PrimitiveArray::from_option_iter([Some(1i32), None, Some(3), None, None, Some(9)]) + .into_array(); + let elem_dtype = DType::Primitive(PType::I32, Nullable); + let ranges = [(0, 3), (3, 2), (5, 0), (5, 1)]; + let valid = [true, true, true, true]; + + let groups = listview(elements.clone(), &ranges, &valid)?; + let actual = grouped_sum_actual(&groups, &elem_dtype)?; + let expected = grouped_sum_reference(&elements, &ranges, &valid, &elem_dtype)?; + + let direct = + PrimitiveArray::from_option_iter([Some(4i64), Some(0i64), Some(0i64), Some(9i64)]); + assert_arrays_eq!(&actual, &direct.into_array()); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } + + #[test] + fn listview_overflow_group_is_null() -> VortexResult<()> { + let elements = + PrimitiveArray::new(buffer![i64::MAX, 1, 2, 3], Validity::NonNullable).into_array(); + let elem_dtype = DType::Primitive(PType::I64, NonNullable); + let ranges = [(0, 2), (2, 2)]; + let valid = [true, true]; + + let groups = listview(elements.clone(), &ranges, &valid)?; + let actual = grouped_sum_actual(&groups, &elem_dtype)?; + let expected = grouped_sum_reference(&elements, &ranges, &valid, &elem_dtype)?; + + // First group overflows -> null sum; second group sums normally. + let direct = PrimitiveArray::from_option_iter([None, Some(5i64)]); + assert_arrays_eq!(&actual, &direct.into_array()); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } + + #[test] + fn listview_float_nan_and_inf() -> VortexResult<()> { + let elements = PrimitiveArray::new( + buffer![1.0f64, f64::NAN, 2.0, f64::INFINITY, f64::NEG_INFINITY, 4.0], + Validity::NonNullable, + ) + .into_array(); + let elem_dtype = DType::Primitive(PType::F64, NonNullable); + let ranges = [(0, 3), (3, 3)]; + let valid = [true, true]; + + let groups = listview(elements.clone(), &ranges, &valid)?; + let actual = grouped_sum_actual(&groups, &elem_dtype)?; + + // Group 0: NaN skipped -> 3.0. Group 1: INF + -INF = NaN. (Avoid array equality here since + // NaN != NaN; compare element scalars against the reference path instead.) + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let expected = grouped_sum_reference(&elements, &ranges, &valid, &elem_dtype)?; + let g0 = actual.execute_scalar(0, &mut ctx)?; + assert_eq!(g0.as_primitive().typed_value::(), Some(3.0)); + assert_eq!( + g0.as_primitive().typed_value::(), + expected + .execute_scalar(0, &mut ctx)? + .as_primitive() + .typed_value::() + ); + let g1 = actual.execute_scalar(1, &mut ctx)?; + assert!(g1.as_primitive().typed_value::().unwrap().is_nan()); + assert!( + expected + .execute_scalar(1, &mut ctx)? + .as_primitive() + .typed_value::() + .unwrap() + .is_nan() + ); + Ok(()) + } + + #[test] + fn fixed_size_overflow_and_nan() -> VortexResult<()> { + // FixedSize path: first group overflows -> null sum, second sums normally. + let elements = + PrimitiveArray::new(buffer![i64::MAX, 1, 2, 3], Validity::NonNullable).into_array(); + let elem_dtype = DType::Primitive(PType::I64, NonNullable); + let groups = FixedSizeListArray::try_new(elements.clone(), 2, Validity::NonNullable, 2)? + .into_array(); + + let actual = grouped_sum_actual(&groups, &elem_dtype)?; + let expected = + grouped_sum_reference(&elements, &[(0, 2), (2, 2)], &[true, true], &elem_dtype)?; + let direct = PrimitiveArray::from_option_iter([None, Some(5i64)]); + assert_arrays_eq!(&actual, &direct.into_array()); + assert_arrays_eq!(&actual, &expected); + Ok(()) + } +} diff --git a/vortex-array/src/aggregate_fn/fns/sum/mod.rs b/vortex-array/src/aggregate_fn/fns/sum/mod.rs index fb5f671e65d..9d525bec742 100644 --- a/vortex-array/src/aggregate_fn/fns/sum/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/sum/mod.rs @@ -4,8 +4,9 @@ mod bool; mod constant; mod decimal; +mod grouped; mod primitive; - +pub(crate) use grouped::PrimitiveGroupedSumEncodingKernel; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_bail; @@ -347,6 +348,7 @@ mod tests { use crate::arrays::ConstantArray; use crate::arrays::DecimalArray; use crate::arrays::FixedSizeListArray; + use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; use crate::assert_arrays_eq; use crate::dtype::DType; @@ -616,6 +618,25 @@ mod tests { Ok(()) } + #[test] + fn grouped_sum_listview_out_of_order_offsets_with_null_group() -> VortexResult<()> { + let elements = + PrimitiveArray::new(buffer![100i32, 200, 300], Validity::NonNullable).into_array(); + let offsets = PrimitiveArray::new(buffer![2i32, 0, 1], Validity::NonNullable).into_array(); + let sizes = PrimitiveArray::new(buffer![1i32, 1, 1], Validity::NonNullable).into_array(); + let validity = Validity::from_iter([true, false, true]); + let groups = ListViewArray::try_new(elements, offsets, sizes, validity)?.into_array(); + + let elem_dtype = DType::Primitive(PType::I32, Nullability::NonNullable); + let result = run_grouped_sum(&groups, &elem_dtype)?; + + // group 0 -> elements[2..3] = 300; group 1 -> null; group 2 -> elements[1..2] = 200. + let expected = + PrimitiveArray::from_option_iter([Some(300i64), None, Some(200i64)]).into_array(); + assert_arrays_eq!(&result, &expected); + Ok(()) + } + // Chunked array tests #[test] diff --git a/vortex-array/src/aggregate_fn/fns/sum/primitive.rs b/vortex-array/src/aggregate_fn/fns/sum/primitive.rs index 44418fb5628..df7d929d896 100644 --- a/vortex-array/src/aggregate_fn/fns/sum/primitive.rs +++ b/vortex-array/src/aggregate_fn/fns/sum/primitive.rs @@ -50,11 +50,7 @@ fn accumulate_primitive_all(inner: &mut SumState, p: &PrimitiveArray) -> VortexR unsigned: |_T| { vortex_panic!("float sum state with unsigned input") }, signed: |_T| { vortex_panic!("float sum state with signed input") }, floating: |T| { - for &v in p.as_slice::() { - if !v.is_nan() { - *acc += ToPrimitive::to_f64(&v).vortex_expect("float to f64"); - } - } + sum_float_all(acc, p.as_slice::()); Ok(false) } ), @@ -62,11 +58,21 @@ fn accumulate_primitive_all(inner: &mut SumState, p: &PrimitiveArray) -> VortexR } } +/// Sum the non-NaN values of a float slice into an `f64` accumulator. NaNs are skipped to match the +/// scalar `sum` semantics. Floats cannot overflow the accumulator, so this never reports saturation. +pub(super) fn sum_float_all(acc: &mut f64, slice: &[T]) { + for &v in slice { + if !v.is_nan() { + *acc += ToPrimitive::to_f64(&v).vortex_expect("float to f64"); + } + } +} + /// Sum all values into a `u64` accumulator. For types narrower than 64 bits, values are summed in /// chunks of [`SUM_CHUNK`] with a single checked add per chunk, which lets the inner loop vectorize /// to packed widening adds. `u64` input keeps a per-element checked add since a chunk of `u64`s /// could itself overflow. Returns `true` on overflow. -fn sum_unsigned_all(acc: &mut u64, slice: &[T]) -> bool +pub(super) fn sum_unsigned_all(acc: &mut u64, slice: &[T]) -> bool where T: NativePType + AsPrimitive, { @@ -88,7 +94,7 @@ where } /// Signed counterpart of [`sum_unsigned_all`]. -fn sum_signed_all(acc: &mut i64, slice: &[T]) -> bool +pub(super) fn sum_signed_all(acc: &mut i64, slice: &[T]) -> bool where T: NativePType + AsPrimitive, { @@ -150,11 +156,7 @@ fn accumulate_primitive_valid( floating: |T| { let values = p.as_slice::(); for &(start, end) in slices { - for &v in &values[start..end] { - if !v.is_nan() { - *acc += ToPrimitive::to_f64(&v).vortex_expect("float to f64"); - } - } + sum_float_all(acc, &values[start..end]); } Ok(false) } diff --git a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/list_view.rs b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/list_view.rs index 919430195d9..f75fcb92025 100644 --- a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/list_view.rs +++ b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/list_view.rs @@ -18,7 +18,7 @@ pub(super) fn list_view_uncompressed_size_in_bytes( let mut size = if array.is_empty() { 0 } else { - let rebuilt = array.rebuild(ListViewRebuildMode::MakeExact)?; + let rebuilt = array.rebuild(ListViewRebuildMode::MakeExact, ctx)?; uncompressed_size_in_bytes_u64(rebuilt.elements(), ctx)? }; diff --git a/vortex-array/src/aggregate_fn/kernels.rs b/vortex-array/src/aggregate_fn/kernels.rs index d806b18d84d..c5af0902cbb 100644 --- a/vortex-array/src/aggregate_fn/kernels.rs +++ b/vortex-array/src/aggregate_fn/kernels.rs @@ -11,8 +11,7 @@ use vortex_error::VortexResult; use crate::ArrayRef; use crate::ExecutionCtx; use crate::aggregate_fn::AggregateFnRef; -use crate::arrays::FixedSizeListArray; -use crate::arrays::ListViewArray; +use crate::aggregate_fn::GroupedArray; use crate::scalar::Scalar; /// A pluggable kernel for an aggregate function. @@ -30,34 +29,24 @@ pub trait DynAggregateKernel: 'static + Send + Sync + Debug { /// A pluggable kernel for batch aggregation of many groups. /// -/// The kernel is matched on the encoding of the _elements_ array, which is the inner array of the -/// provided `ListViewArray`. This is more pragmatic than having every kernel match on the outer -/// list encoding and having to deal with the possibility of multiple list encodings. +/// A kernel can be registered either for an aggregate function regardless of the element encoding, +/// or for a specific aggregate function and element encoding. Element-encoding kernels are matched +/// on the inner array of the provided grouped array, not on the outer list encoding. This is more +/// pragmatic than having every kernel match on the outer list encoding and having to deal with the +/// possibility of multiple list encodings. /// -/// Each element of the list array represents a group and the result of the grouped aggregate +/// Each value in the grouped array represents a group and the result of the grouped aggregate /// should be an array of the same length, where each element is the aggregate state of the /// corresponding group. /// /// Return `Ok(None)` if the kernel cannot be applied to the given aggregate function. pub trait DynGroupedAggregateKernel: 'static + Send + Sync + Debug { - /// Aggregate each group in the provided `ListViewArray` and return an array of the - /// aggregate states. + /// Aggregate each group in the provided grouped array and return an array of the aggregate + /// states. fn grouped_aggregate( &self, aggregate_fn: &AggregateFnRef, - groups: &ListViewArray, + groups: &GroupedArray, + ctx: &mut ExecutionCtx, ) -> VortexResult>; - - /// Aggregate each group in the provided `FixedSizeListArray` and return an array of the - /// aggregate states. - fn grouped_aggregate_fixed_size( - &self, - aggregate_fn: &AggregateFnRef, - groups: &FixedSizeListArray, - ) -> VortexResult> { - // TODO(ngates): we could automatically delegate to `grouped_aggregate` if SequenceArray - // was in the vortex-array crate - let _ = (aggregate_fn, groups); - Ok(None) - } } diff --git a/vortex-array/src/aggregate_fn/proto.rs b/vortex-array/src/aggregate_fn/proto.rs index 9bcfab5818c..bff198b3955 100644 --- a/vortex-array/src/aggregate_fn/proto.rs +++ b/vortex-array/src/aggregate_fn/proto.rs @@ -36,7 +36,7 @@ impl AggregateFnRef { /// Note: the serialization format is not stable and may change between versions. pub fn from_proto(proto: &pb::AggregateFn, session: &VortexSession) -> VortexResult { let agg_fn_id: AggregateFnId = AggregateFnId::new(proto.id.as_str()); - let agg_fn = if let Some(plugin) = session.aggregate_fns().registry().find(&agg_fn_id) { + let agg_fn = if let Some(plugin) = session.aggregate_fns().find_plugin(&agg_fn_id) { plugin.deserialize(proto.metadata(), session)? } else if session.allows_unknown() { new_foreign_aggregate_fn(agg_fn_id, proto.metadata().to_vec()) diff --git a/vortex-array/src/aggregate_fn/session.rs b/vortex-array/src/aggregate_fn/session.rs index d89f9da069d..c6d7542a687 100644 --- a/vortex-array/src/aggregate_fn/session.rs +++ b/vortex-array/src/aggregate_fn/session.rs @@ -4,12 +4,9 @@ use std::any::Any; use std::sync::Arc; -use parking_lot::RwLock; use vortex_session::Ref; use vortex_session::SessionExt; use vortex_session::SessionVar; -use vortex_session::registry::Registry; -use vortex_utils::aliases::hash_map::HashMap; use crate::aggregate_fn::AggregateFnId; use crate::aggregate_fn::AggregateFnPluginRef; @@ -21,6 +18,8 @@ use crate::aggregate_fn::fns::all_non_null::AllNonNull; use crate::aggregate_fn::fns::all_null::AllNull; use crate::aggregate_fn::fns::bounded_max::BoundedMax; use crate::aggregate_fn::fns::bounded_min::BoundedMin; +use crate::aggregate_fn::fns::count::Count; +use crate::aggregate_fn::fns::count::CountGroupedKernel; use crate::aggregate_fn::fns::first::First; use crate::aggregate_fn::fns::is_constant::IsConstant; use crate::aggregate_fn::fns::is_sorted::IsSorted; @@ -30,29 +29,35 @@ use crate::aggregate_fn::fns::min::Min; use crate::aggregate_fn::fns::min_max::MinMax; use crate::aggregate_fn::fns::nan_count::NanCount; use crate::aggregate_fn::fns::null_count::NullCount; +use crate::aggregate_fn::fns::sum::PrimitiveGroupedSumEncodingKernel; use crate::aggregate_fn::fns::sum::Sum; use crate::aggregate_fn::fns::uncompressed_size_in_bytes::UncompressedSizeInBytes; use crate::aggregate_fn::kernels::DynAggregateKernel; use crate::aggregate_fn::kernels::DynGroupedAggregateKernel; +use crate::arc_swap_map::ArcSwapMap; use crate::array::ArrayId; use crate::array::VTable; use crate::arrays::Chunked; use crate::arrays::Dict; +use crate::arrays::Primitive; use crate::arrays::chunked::compute::aggregate::ChunkedArrayAggregate; use crate::arrays::dict::compute::is_constant::DictIsConstantKernel; use crate::arrays::dict::compute::is_sorted::DictIsSortedKernel; use crate::arrays::dict::compute::min_max::DictMinMaxKernel; -/// Registry of aggregate function vtables. -pub type AggregateFnRegistry = Registry; - -/// Session state for aggregate function vtables. +/// Session state for aggregate functions and encoding-specific aggregate kernels. +/// +/// The default session registers the built-in aggregate functions and kernels. Additional +/// aggregate functions and kernels may be registered by extensions when they are added to a +/// [`VortexSession`](vortex_session::VortexSession). #[derive(Debug)] pub struct AggregateFnSession { - registry: AggregateFnRegistry, + registry: ArcSwapMap, - pub(super) kernels: RwLock>, - pub(super) grouped_kernels: RwLock>, + kernels: ArcSwapMap, + grouped_kernels: ArcSwapMap, + grouped_encoding_kernels: + ArcSwapMap, } impl SessionVar for AggregateFnSession { @@ -65,14 +70,16 @@ impl SessionVar for AggregateFnSession { } } -type KernelKey = (ArrayId, Option); +type AggregateKernelKey = (ArrayId, Option); +type GroupedEncodingKernelKey = (ArrayId, AggregateFnId); impl Default for AggregateFnSession { fn default() -> Self { let this = Self { - registry: AggregateFnRegistry::default(), - kernels: RwLock::new(HashMap::default()), - grouped_kernels: RwLock::new(HashMap::default()), + registry: ArcSwapMap::default(), + kernels: ArcSwapMap::default(), + grouped_kernels: ArcSwapMap::default(), + grouped_encoding_kernels: ArcSwapMap::default(), }; // Register the built-in aggregate functions @@ -101,39 +108,120 @@ impl Default for AggregateFnSession { this.register_aggregate_kernel(Dict.id(), Some(IsConstant.id()), &DictIsConstantKernel); this.register_aggregate_kernel(Dict.id(), Some(IsSorted.id()), &DictIsSortedKernel); + // Register the built-in grouped aggregate kernels. + this.register_grouped_kernel(Count.id(), &CountGroupedKernel); + this.register_grouped_encoding_kernel( + Primitive.id(), + Sum.id(), + &PrimitiveGroupedSumEncodingKernel, + ); + this } } impl AggregateFnSession { - /// Returns the aggregate function registry. - pub fn registry(&self) -> &AggregateFnRegistry { - &self.registry + /// Returns the aggregate function plugin registered for `id`, if any. + pub fn find_plugin(&self, id: &AggregateFnId) -> Option { + self.registry.get(id) } /// Register an aggregate function vtable in the session, replacing any existing vtable with /// the same ID. pub fn register(&self, vtable: V) { - self.registry - .register(vtable.id(), Arc::new(vtable) as AggregateFnPluginRef); + let id = vtable.id(); + let pluginref = Arc::new(vtable) as AggregateFnPluginRef; + self.registry.insert(id, pluginref); + } + + /// Returns the aggregate kernel registered for `array_id` and `agg_fn_id`, if any. + /// + /// Lookup first checks for a kernel registered for the exact aggregate function, then falls + /// back to a kernel registered for all aggregate functions on the same array encoding. + pub fn find_aggregate_kernel( + &self, + array_id: impl Into, + agg_fn_id: impl Into, + ) -> Option<&'static dyn DynAggregateKernel> { + let id = array_id.into(); + let fn_id = agg_fn_id.into(); + self.kernels.read(|kernels| { + kernels + .get(&(id, Some(fn_id))) + .or_else(|| kernels.get(&(id, None))) + .copied() + }) } - /// Register an aggregate function kernel for a specific aggregate function and array type. + /// Registers an aggregate kernel for an array encoding. + /// + /// When `agg_fn_id` is `Some`, the kernel is used only for that aggregate function. When + /// `agg_fn_id` is `None`, the kernel is used as the fallback for aggregate functions on the + /// array encoding that do not have a more specific kernel. pub fn register_aggregate_kernel( &self, array_id: impl Into, agg_fn_id: Option>, kernel: &'static dyn DynAggregateKernel, ) { - self.kernels - .write() - .insert((array_id.into(), agg_fn_id.map(|id| id.into())), kernel); + let id = (array_id.into(), agg_fn_id.map(|id| id.into())); + self.kernels.insert(id, kernel); + } + + /// Returns the grouped aggregate kernel registered for `agg_fn_id`, if any. + /// + /// These kernels are independent of the element encoding and are checked for each element + /// representation, after any kernel registered for the current element encoding. + pub fn find_grouped_kernel( + &self, + agg_fn_id: impl Into, + ) -> Option<&'static dyn DynGroupedAggregateKernel> { + let fn_id = agg_fn_id.into(); + self.grouped_kernels + .read(|kernels| kernels.get(&fn_id).copied()) + } + + /// Registers a grouped aggregate kernel for an aggregate function. + pub fn register_grouped_kernel( + &self, + agg_fn_id: impl Into, + kernel: &'static dyn DynGroupedAggregateKernel, + ) { + let fn_id = agg_fn_id.into(); + self.grouped_kernels.insert(fn_id, kernel) + } + + /// Returns the grouped aggregate kernel registered for `array_id` and `agg_fn_id`, if any. + /// + /// These kernels are matched against each intermediate element encoding while the grouped + /// accumulator executes the element array. + pub fn find_grouped_encoding_kernel( + &self, + array_id: impl Into, + agg_fn_id: impl Into, + ) -> Option<&'static dyn DynGroupedAggregateKernel> { + let id = array_id.into(); + let fn_id = agg_fn_id.into(); + self.grouped_encoding_kernels + .read(|kernels| kernels.get(&(id, fn_id)).copied()) + } + + /// Registers a grouped aggregate kernel for a specific aggregate function and array encoding. + pub fn register_grouped_encoding_kernel( + &self, + array_id: impl Into, + agg_fn_id: impl Into, + kernel: &'static dyn DynGroupedAggregateKernel, + ) { + let id = array_id.into(); + let fn_id = agg_fn_id.into(); + self.grouped_encoding_kernels.insert((id, fn_id), kernel) } } /// Extension trait for accessing aggregate function session data. pub trait AggregateFnSessionExt: SessionExt { - /// Returns the aggregate function vtable registry. + /// Returns the aggregate function session data. fn aggregate_fns(&self) -> Ref<'_, AggregateFnSession> { self.get::() } diff --git a/vortex-array/src/arc_swap_map.rs b/vortex-array/src/arc_swap_map.rs new file mode 100644 index 00000000000..aff46c22d73 --- /dev/null +++ b/vortex-array/src/arc_swap_map.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! A concurrent, copy-on-write map backed by an [`ArcSwap`]. + +use std::borrow::Borrow; +use std::fmt::Debug; +use std::fmt::Formatter; +use std::hash::Hash; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use vortex_utils::aliases::hash_map::HashMap; + +/// A concurrent [`HashMap`] backed by an [`ArcSwap`], offering lock-free reads +/// and copy-on-write writes. +/// +/// Reads load the current snapshot without blocking writers. Writes clone the +/// whole map, apply their change, and atomically publish the new version, so a +/// reader always observes a consistent snapshot and writers never block readers. +/// +/// This is the shared building block behind the session-scoped registries (the +/// optimizer-kernel and aggregate-function registries). Because every write +/// clones the entire map, it is intended for maps that are written rarely +/// (typically only while a session is being configured) and read often. +pub(crate) struct ArcSwapMap { + inner: ArcSwap>, +} + +impl Default for ArcSwapMap { + fn default() -> Self { + Self { + inner: ArcSwap::from_pointee(HashMap::default()), + } + } +} + +impl Debug for ArcSwapMap { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.read(|map| f.debug_tuple("ArcSwapMap").field(map).finish()) + } +} + +impl ArcSwapMap { + /// Read the current snapshot, passing it to `f`. + /// + /// Every lookup inside `f` observes the same snapshot, which matters when a + /// single logical read consults more than one key. + pub(crate) fn read(&self, f: impl FnOnce(&HashMap) -> R) -> R { + f(&self.inner.load()) + } + + /// Replace the map with the result of applying `f` to a private copy. + /// + /// Writes are copy-on-write via [`ArcSwap::rcu`], so `f` may run more than + /// once under contention and must not move out of its captures. + fn modify(&self, f: impl Fn(&mut HashMap)) + where + K: Clone, + V: Clone, + { + self.inner.rcu(|existing| { + let mut map = existing.as_ref().clone(); + f(&mut map); + map + }); + } +} + +impl ArcSwapMap { + /// Return a clone of the value stored under `key`, if present. + pub(crate) fn get(&self, key: &Q) -> Option + where + K: Borrow, + Q: Eq + Hash + ?Sized, + { + self.inner.load().get(key).cloned() + } + + /// Insert `value` under `key`, replacing any existing value. + pub(crate) fn insert(&self, key: K, value: V) + where + K: Clone, + { + self.modify(|map| { + map.insert(key.clone(), value.clone()); + }); + } +} + +impl ArcSwapMap> { + /// Append `values` to the list stored under `key`, creating it if absent. + /// + /// Each key maps to an immutable `Arc<[T]>`; appending rebuilds that slice + /// copy-on-write so existing readers keep their previous snapshot. + pub(crate) fn extend(&self, key: K, values: &[T]) { + self.modify(|map| { + let merged: Arc<[T]> = match map.get(&key) { + Some(existing) => existing.iter().chain(values).cloned().collect(), + None => values.into(), + }; + map.insert(key.clone(), merged); + }); + } + + /// Append a single `value` to the list stored under `key`, creating it if + /// absent. + pub(crate) fn push(&self, key: K, value: T) { + self.extend(key, &[value]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_and_insert() { + let map = ArcSwapMap::::default(); + assert_eq!(map.get(&1), None); + map.insert(1, 10); + map.insert(1, 20); + assert_eq!(map.get(&1), Some(20)); + } + + #[test] + fn extend_appends_per_key() { + let map = ArcSwapMap::>::default(); + map.extend(1, &[1, 2]); + map.extend(1, &[3]); + map.extend(2, &[4]); + assert_eq!(map.get(&1).as_deref(), Some([1, 2, 3].as_slice())); + assert_eq!(map.get(&2).as_deref(), Some([4].as_slice())); + } + + #[test] + fn push_appends_single_values() { + let map = ArcSwapMap::>::default(); + map.push(1, 1); + map.push(1, 2); + assert_eq!(map.get(&1).as_deref(), Some([1, 2].as_slice())); + } + + #[test] + fn read_observes_a_single_snapshot() { + let map = ArcSwapMap::::default(); + map.insert(1, 1); + map.insert(2, 2); + assert_eq!(map.read(|m| m.values().sum::()), 3); + } +} diff --git a/vortex-array/src/array/erased.rs b/vortex-array/src/array/erased.rs index 77800377f1c..bacda957660 100644 --- a/vortex-array/src/array/erased.rs +++ b/vortex-array/src/array/erased.rs @@ -47,13 +47,13 @@ use crate::arrays::VarBinView; use crate::buffer::BufferHandle; use crate::builders::ArrayBuilder; use crate::dtype::DType; -use crate::dtype::Nullability; use crate::expr::stats::Precision; use crate::expr::stats::Stat; use crate::expr::stats::StatsProviderExt; use crate::matcher::Matcher; use crate::optimizer::ArrayOptimizer; use crate::scalar::Scalar; +use crate::scalar::ScalarValue; use crate::stats::StatsSetRef; use crate::validity::Validity; @@ -155,22 +155,22 @@ impl Debug for ArrayRef { } impl ArrayHash for ArrayRef { - fn array_hash(&self, state: &mut H, precision: crate::Precision) { + fn array_hash(&self, state: &mut H, accuracy: crate::EqMode) { self.0.len.hash(state); self.0.dtype.hash(state); self.0.encoding_id.hash(state); self.0.slots.len().hash(state); for slot in &self.0.slots { - slot.array_hash(state, precision); + slot.array_hash(state, accuracy); } self.0 .data - .dyn_array_hash(state as &mut dyn Hasher, precision); + .dyn_array_hash(state as &mut dyn Hasher, accuracy); } } impl ArrayEq for ArrayRef { - fn array_eq(&self, other: &Self, precision: crate::Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: crate::EqMode) -> bool { self.0.len == other.0.len && self.0.dtype == other.0.dtype && self.0.encoding_id == other.0.encoding_id @@ -180,8 +180,8 @@ impl ArrayEq for ArrayRef { .slots .iter() .zip(other.0.slots.iter()) - .all(|(slot, other_slot)| slot.array_eq(other_slot, precision)) - && self.0.data.dyn_array_eq(other, precision) + .all(|(slot, other_slot)| slot.array_eq(other_slot, accuracy)) + && self.0.data.dyn_array_eq(other, accuracy) } } impl ArrayRef { @@ -239,13 +239,10 @@ impl ArrayRef { matches!( stat, Stat::IsConstant | Stat::IsSorted | Stat::IsStrictSorted - ) && value.as_ref().as_exact().is_some_and(|v| { - Scalar::try_new(DType::Bool(Nullability::NonNullable), Some(v.clone())) - .vortex_expect("A stat that was expected to be a boolean stat was not") - .as_bool() - .value() - .unwrap_or_default() - }) + ) && value + .as_ref() + .as_exact() + .is_some_and(|v| matches!(v, ScalarValue::Bool(true))) })); }); } @@ -390,16 +387,19 @@ impl ArrayRef { } /// Does the array match the given matcher. + #[inline] pub fn is(&self) -> bool { M::matches(self) } /// Returns the array downcast by the given matcher. + #[inline] pub fn as_(&self) -> M::Match<'_> { self.as_opt::().vortex_expect("Failed to downcast") } /// Returns the array downcast by the given matcher. + #[inline] pub fn as_opt(&self) -> Option> { M::try_match(self) } @@ -494,7 +494,7 @@ impl ArrayRef { /// Take a slot for executor-owned physical rewrites. /// /// On return the produced parent has the taken slot set to `None` - /// callers must put the slot back (typically via [`put_slot_unchecked`]) before the parent is + /// callers must put the slot back (typically via [`Self::put_slot_unchecked`]) before the parent is /// returned from the execution loop. /// /// When the `Arc` was shared this allocates a fresh parent. @@ -531,7 +531,7 @@ impl ArrayRef { /// Puts an array into `slot_idx` by either, cloning the inner array if the Arc is not exclusive /// or replacing the slot in this `ArrayRef`. - /// This is the mirror of [`take_slot_unchecked`]. + /// This is the mirror of [`Self::take_slot_unchecked`]. /// /// # Safety /// The replacement must have the same logical dtype and length as the taken slot, and this @@ -738,10 +738,12 @@ impl IntoArray for ArrayRef { impl Matcher for V { type Match<'a> = ArrayView<'a, V>; + #[inline] fn matches(array: &ArrayRef) -> bool { array.0.data.as_any().is::>() } + #[inline] fn try_match(array: &'_ ArrayRef) -> Option> { let inner = array.0.data.as_any().downcast_ref::>()?; // # Safety checked by `downcast_ref`. diff --git a/vortex-array/src/array/foreign.rs b/vortex-array/src/array/foreign.rs index a7ee238d1f1..e7ea7238346 100644 --- a/vortex-array/src/array/foreign.rs +++ b/vortex-array/src/array/foreign.rs @@ -49,24 +49,24 @@ impl Display for ForeignArrayData { } impl ArrayHash for ForeignArrayData { - fn array_hash(&self, state: &mut H, precision: crate::Precision) { + fn array_hash(&self, state: &mut H, accuracy: crate::EqMode) { self.metadata.hash(state); self.buffers.len().hash(state); for buffer in &self.buffers { - buffer.array_hash(state, precision); + buffer.array_hash(state, accuracy); } } } impl ArrayEq for ForeignArrayData { - fn array_eq(&self, other: &Self, precision: crate::Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: crate::EqMode) -> bool { self.metadata == other.metadata && self.buffers.len() == other.buffers.len() && self .buffers .iter() .zip(other.buffers.iter()) - .all(|(lhs, rhs)| lhs.array_eq(rhs, precision)) + .all(|(lhs, rhs)| lhs.array_eq(rhs, accuracy)) } } diff --git a/vortex-array/src/array/mod.rs b/vortex-array/src/array/mod.rs index 23ad9499766..b1b22c4c529 100644 --- a/vortex-array/src/array/mod.rs +++ b/vortex-array/src/array/mod.rs @@ -119,10 +119,10 @@ pub(crate) trait DynArrayData: 'static + private::Sealed + Send + Sync + Debug { fn metadata_fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result; /// Hashes the array contents including len, dtype, and encoding id. - fn dyn_array_hash(&self, state: &mut dyn Hasher, precision: crate::Precision); + fn dyn_array_hash(&self, state: &mut dyn Hasher, accuracy: crate::EqMode); /// Compares two arrays of the same concrete type for equality. - fn dyn_array_eq(&self, other: &ArrayRef, precision: crate::Precision) -> bool; + fn dyn_array_eq(&self, other: &ArrayRef, accuracy: crate::EqMode) -> bool; /// Returns a new array with the given slots. fn with_slots(&self, this: &ArrayRef, slots: ArraySlots) -> VortexResult; @@ -341,19 +341,19 @@ impl DynArrayData for ArrayData { std::fmt::Display::fmt(&self.data, f) } - fn dyn_array_hash(&self, state: &mut dyn Hasher, precision: crate::Precision) { + fn dyn_array_hash(&self, state: &mut dyn Hasher, accuracy: crate::EqMode) { let mut wrapper = HasherWrapper(state); // Note: metadata (len, dtype, encoding_id) and slots are hashed by ArrayRef. - self.data.array_hash(&mut wrapper, precision); + self.data.array_hash(&mut wrapper, accuracy); } - fn dyn_array_eq(&self, other: &ArrayRef, precision: crate::Precision) -> bool { + fn dyn_array_eq(&self, other: &ArrayRef, accuracy: crate::EqMode) -> bool { // Note: metadata (len, dtype, encoding_id) and slots are compared by ArrayRef. other .dyn_array() .as_any() .downcast_ref::() - .is_some_and(|other_inner| self.data.array_eq(&other_inner.data, precision)) + .is_some_and(|other_inner| self.data.array_eq(&other_inner.data, accuracy)) } fn with_slots(&self, this: &ArrayRef, slots: ArraySlots) -> VortexResult { diff --git a/vortex-array/src/array/vtable/mod.rs b/vortex-array/src/array/vtable/mod.rs index 6c85bb79d05..366eed77ad9 100644 --- a/vortex-array/src/array/vtable/mod.rs +++ b/vortex-array/src/array/vtable/mod.rs @@ -22,9 +22,9 @@ use crate::Array; use crate::ArrayRef; use crate::ArrayView; use crate::Canonical; +use crate::EqMode; use crate::ExecutionResult; use crate::IntoArray; -use crate::Precision; pub use crate::array::plugin::*; use crate::arrays::ConstantArray; use crate::arrays::constant::Constant; @@ -223,12 +223,12 @@ use crate::array::ArrayId; pub struct EmptyArrayData; impl ArrayEq for EmptyArrayData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } impl ArrayHash for EmptyArrayData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl Display for EmptyArrayData { diff --git a/vortex-array/src/arrays/bool/array.rs b/vortex-array/src/arrays/bool/array.rs index 6585e705899..82df2f9c167 100644 --- a/vortex-array/src/arrays/bool/array.rs +++ b/vortex-array/src/arrays/bool/array.rs @@ -7,7 +7,9 @@ use std::fmt::Formatter; use arrow_array::BooleanArray; use smallvec::smallvec; use vortex_buffer::BitBuffer; +use vortex_buffer::BitBufferMeta; use vortex_buffer::BitBufferMut; +use vortex_buffer::BitBufferView; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_ensure; @@ -69,19 +71,18 @@ pub(super) const SLOT_NAMES: [&str; NUM_SLOTS] = ["validity"]; #[derive(Clone, Debug)] pub struct BoolData { pub(super) bits: BufferHandle, - pub(super) offset: usize, + pub(super) meta: BitBufferMeta, } impl Display for BoolData { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "offset: {}", self.offset) + write!(f, "offset: {}", self.meta.offset()) } } pub struct BoolDataParts { pub bits: BufferHandle, - pub offset: usize, - pub len: usize, + pub meta: BitBufferMeta, } pub trait BoolArrayExt: TypedArrayRef { @@ -101,7 +102,12 @@ pub trait BoolArrayExt: TypedArrayRef { fn to_bit_buffer(&self) -> BitBuffer { let buffer = self.bits.as_host().clone(); - BitBuffer::new_with_offset(buffer, self.as_ref().len(), self.offset) + BitBuffer::new_with_offset(buffer, self.meta.len(), self.meta.offset()) + } + + /// Borrow the array's packed bits as a [`BitBufferView`] without cloning the backing buffer. + fn bit_buffer_view(&self) -> BitBufferView<'_> { + BitBufferView::from_meta(self.bits.as_host().as_slice(), self.meta) } fn maybe_execute_mask(&self, ctx: &mut ExecutionCtx) -> VortexResult> { @@ -141,8 +147,7 @@ impl BoolData { pub fn into_parts(self, len: usize) -> BoolDataParts { BoolDataParts { bits: self.bits, - offset: self.offset, - len, + meta: BitBufferMeta::new(self.meta.offset(), len), } } @@ -151,9 +156,9 @@ impl BoolData { } } -/// Constructors and consuming methods for `BoolArray` (`Array`). +/// Constructors and consuming methods for [`BoolArray`]. impl Array { - /// Constructs a new `BoolArray`. + /// Constructs a new [`BoolArray`]. /// /// # Panics /// @@ -162,7 +167,7 @@ impl Array { Self::try_new(bits, validity).vortex_expect("Failed to create BoolArray") } - /// Constructs a new `BoolArray` from a `BufferHandle`. + /// Constructs a new [`BoolArray`] from a [`BufferHandle`]. /// /// # Panics /// @@ -242,21 +247,21 @@ impl Array { let len = self.len(); let data = self.into_data(); let buffer = data.bits.unwrap_host(); - BitBuffer::new_with_offset(buffer, len, data.offset) + BitBuffer::new_with_offset(buffer, len, data.meta.offset()) } } -/// Internal constructors on BoolData (used by Array constructors and VTable::build). +// Internal constructors on BoolData (used by [`BoolArray`] constructors and [`VTable::build`]). impl BoolData { pub(super) fn try_new(bits: BitBuffer, validity: Validity) -> VortexResult { let bits = bits.shrink_offset(); Self::validate(&bits, &validity)?; - let (offset, _len, buffer) = bits.into_inner(); + let (offset, len, buffer) = bits.into_inner(); Ok(Self { bits: BufferHandle::new_host(buffer), - offset, + meta: BitBufferMeta::new(offset, len), }) } @@ -281,18 +286,21 @@ impl BoolData { bits.len() * 8, ); - Ok(Self { bits, offset }) + Ok(Self { + bits, + meta: BitBufferMeta::new(offset, len), + }) } pub(super) unsafe fn new_unchecked(bits: BitBuffer, validity: Validity) -> Self { if cfg!(debug_assertions) { Self::try_new(bits, validity).vortex_expect("Failed to create BoolData") } else { - let (offset, _len, buffer) = bits.into_inner(); + let (offset, len, buffer) = bits.into_inner(); Self { bits: BufferHandle::new_host(buffer), - offset, + meta: BitBufferMeta::new(offset, len), } } } diff --git a/vortex-array/src/arrays/bool/compute/mod.rs b/vortex-array/src/arrays/bool/compute/mod.rs index 9649bd31d6c..9b71578cd84 100644 --- a/vortex-array/src/arrays/bool/compute/mod.rs +++ b/vortex-array/src/arrays/bool/compute/mod.rs @@ -8,6 +8,7 @@ mod mask; pub mod rules; mod slice; mod take; +mod zip; #[cfg(test)] mod tests { diff --git a/vortex-array/src/arrays/bool/compute/rules.rs b/vortex-array/src/arrays/bool/compute/rules.rs index ed2e7a4dc28..43f811785ea 100644 --- a/vortex-array/src/arrays/bool/compute/rules.rs +++ b/vortex-array/src/arrays/bool/compute/rules.rs @@ -45,14 +45,15 @@ impl ArrayParentReduceRule for BoolMaskedValidityRule { return Ok(None); } + let bit_buffer = array.to_bit_buffer(); // Merge the parent's validity mask into the child's validity // TODO(joe): make this lazy - Ok(Some( - BoolArray::new( - array.to_bit_buffer(), - array.validity()?.and(parent.validity()?)?, - ) - .into_array(), - )) + let validity = array.validity()?.and(parent.validity()?)?; + + // Safety: + // we know all elements are valid, the AND operation will fail if mismatched. + let array = unsafe { BoolArray::new_unchecked(bit_buffer, validity).into_array() }; + + Ok(Some(array)) } } diff --git a/vortex-array/src/arrays/bool/compute/slice.rs b/vortex-array/src/arrays/bool/compute/slice.rs index 0ef11bb3085..0b38d2a6117 100644 --- a/vortex-array/src/arrays/bool/compute/slice.rs +++ b/vortex-array/src/arrays/bool/compute/slice.rs @@ -15,12 +15,13 @@ use crate::arrays::slice::SliceReduce; impl SliceReduce for Bool { fn slice(array: ArrayView<'_, Bool>, range: Range) -> VortexResult> { - Ok(Some( - BoolArray::new( - array.to_bit_buffer().slice(range.clone()), - array.validity()?.slice(range)?, - ) - .into_array(), - )) + let bit_buffer = array.to_bit_buffer().slice(range.clone()); + let validity = array.validity()?.slice(range)?; + + // Safety: + // range is verified in the callers and is the same for both bits and validity. + let array = unsafe { BoolArray::new_unchecked(bit_buffer, validity).into_array() }; + + Ok(Some(array)) } } diff --git a/vortex-array/src/arrays/bool/compute/take.rs b/vortex-array/src/arrays/bool/compute/take.rs index 1f679445254..d70579136a4 100644 --- a/vortex-array/src/arrays/bool/compute/take.rs +++ b/vortex-array/src/arrays/bool/compute/take.rs @@ -4,6 +4,7 @@ use itertools::Itertools as _; use num_traits::AsPrimitive; use vortex_buffer::BitBuffer; +use vortex_buffer::BitBufferView; use vortex_buffer::get_bit; use vortex_error::VortexResult; use vortex_mask::Mask; @@ -42,7 +43,10 @@ impl TakeExecute for Bool { }; let indices_nulls_zeroed = indices_nulls_zeroed.execute::(ctx)?; let buffer = match_each_integer_ptype!(indices_nulls_zeroed.ptype(), |I| { - take_valid_indices(&array.to_bit_buffer(), indices_nulls_zeroed.as_slice::()) + take_valid_indices( + array.bit_buffer_view(), + indices_nulls_zeroed.as_slice::(), + ) }); Ok(Some( @@ -51,7 +55,7 @@ impl TakeExecute for Bool { } } -fn take_valid_indices>(bools: &BitBuffer, indices: &[I]) -> BitBuffer { +fn take_valid_indices>(bools: BitBufferView<'_>, indices: &[I]) -> BitBuffer { // For boolean arrays that roughly fit into a single page (at least, on Linux), it's worth // the overhead to convert to a Vec. if bools.len() <= 4096 { @@ -68,9 +72,9 @@ fn take_byte_bool>(bools: Vec, indices: &[I]) -> Bit }) } -fn take_bool_impl>(bools: &BitBuffer, indices: &[I]) -> BitBuffer { +fn take_bool_impl>(bools: BitBufferView<'_>, indices: &[I]) -> BitBuffer { // We dereference to underlying buffer to avoid access cost on every index. - let buffer = bools.inner().as_ref(); + let buffer = bools.inner(); BitBuffer::collect_bool(indices.len(), |idx| { // SAFETY: we can take from the indices unchecked since collect_bool just iterates len. let idx = unsafe { indices.get_unchecked(idx).as_() }; diff --git a/vortex-array/src/arrays/bool/compute/zip.rs b/vortex-array/src/arrays/bool/compute/zip.rs new file mode 100644 index 00000000000..2628b457a85 --- /dev/null +++ b/vortex-array/src/arrays/bool/compute/zip.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_buffer::BitBuffer; +use vortex_buffer::BufferMut; +use vortex_error::VortexResult; +use vortex_mask::Mask; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::array::ArrayView; +use crate::arrays::Bool; +use crate::arrays::BoolArray; +use crate::arrays::bool::BoolArrayExt; +use crate::scalar_fn::fns::zip::ZipKernel; +use crate::scalar_fn::fns::zip::zip_validity; + +/// A branchless boolean zip kernel that blends the two value bitmaps with the mask in one pass. +/// +/// Booleans are bit-packed, so selecting `if_true` where the mask is set and `if_false` where it is +/// not is a single bitwise blend over the packed words — `(true & mask) | (false & !mask)` — instead +/// of the generic per-run builder. Validity is combined with the shared `zip_validity`, which itself +/// reuses this kernel (terminating immediately, since validity bitmaps are non-nullable). +impl ZipKernel for Bool { + fn zip( + if_true: ArrayView<'_, Bool>, + if_false: &ArrayRef, + mask: &ArrayRef, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { + let Some(if_false) = if_false.as_opt::() else { + return Ok(None); + }; + + // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics. + let mask = mask.try_to_mask_fill_null_false(ctx)?; + let mask_values = match &mask { + // Defer trivial masks to the generic zip, which just casts the surviving side. + Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None), + Mask::Values(values) => values, + }; + let mask_bits = mask_values.bit_buffer(); + + let values = zip_value_bits( + &if_true.to_bit_buffer(), + &if_false.to_bit_buffer(), + mask_bits, + ); + + let validity = zip_validity(if_true.validity()?, if_false.validity()?, &mask)?; + + Ok(Some(BoolArray::new(values, validity).into_array())) + } +} + +fn zip_value_bits(if_true: &BitBuffer, if_false: &BitBuffer, mask: &BitBuffer) -> BitBuffer { + assert_eq!(if_true.len(), if_false.len()); + assert_eq!(if_true.len(), mask.len()); + + let true_chunks = if_true.chunks(); + let false_chunks = if_false.chunks(); + let mask_chunks = mask.chunks(); + + let mut values = BufferMut::::with_capacity(true_chunks.num_u64s()); + for ((true_bits, false_bits), mask_bits) in true_chunks + .iter() + .zip(false_chunks.iter()) + .zip(mask_chunks.iter()) + { + values.push((true_bits & mask_bits) | (false_bits & !mask_bits)); + } + + if true_chunks.remainder_len() != 0 { + let true_bits = true_chunks.remainder_bits(); + let false_bits = false_chunks.remainder_bits(); + let mask_bits = mask_chunks.remainder_bits(); + values.push((true_bits & mask_bits) | (false_bits & !mask_bits)); + } + + BitBuffer::new(values.freeze().into_byte_buffer(), if_true.len()) +} + +#[cfg(test)] +mod tests { + use vortex_error::VortexResult; + use vortex_mask::Mask; + + use super::zip_value_bits; + use crate::ArrayRef; + use crate::IntoArray; + use crate::LEGACY_SESSION; + use crate::VortexSessionExecute; + use crate::arrays::Bool; + use crate::arrays::BoolArray; + use crate::assert_arrays_eq; + use crate::builtins::ArrayBuiltins; + + #[test] + fn blend_value_bits_boundaries() { + for len in [0usize, 1, 2, 7, 8, 9, 63, 64, 65, 127, 128] { + let if_true = (0..len).map(|i| i.is_multiple_of(2)).collect(); + let if_false = (0..len).map(|i| i.is_multiple_of(3)).collect(); + let mask = (0..len).map(|i| i % 3 != 1).collect(); + + let values = zip_value_bits(&if_true, &if_false, &mask); + + assert_eq!(values.len(), len); + assert_eq!( + values.iter().collect::>(), + (0..len) + .map(|i| { + if i % 3 != 1 { + i.is_multiple_of(2) + } else { + i.is_multiple_of(3) + } + }) + .collect::>(), + "failed for len {len}", + ); + } + } + + /// Blend two non-nullable bool arrays across the 64-bit mask chunk boundary + remainder. + #[test] + fn zip_nonnull_spans_mask_chunks() -> VortexResult<()> { + let len = 150usize; + let if_true = BoolArray::from_iter((0..len).map(|i| i.is_multiple_of(2))).into_array(); + let if_false = BoolArray::from_iter((0..len).map(|i| i.is_multiple_of(3))).into_array(); + + let bits: Vec = (0..len).map(|i| i.is_multiple_of(5) || i == 64).collect(); + let mask = Mask::from_iter(bits.iter().copied()); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + assert!(result.is::()); + + let expected = BoolArray::from_iter((0..len).map(|i| { + if bits[i] { + i.is_multiple_of(2) + } else { + i.is_multiple_of(3) + } + })) + .into_array(); + assert_arrays_eq!(result, expected); + Ok(()) + } + + /// With `Validity::Array` on both sides, select values and validity from the chosen side. + #[test] + fn zip_nullable_selects_values_and_validity() -> VortexResult<()> { + let len = 130usize; + let if_true = BoolArray::from_iter( + (0..len).map(|i| (!i.is_multiple_of(4)).then_some(i.is_multiple_of(2))), + ) + .into_array(); + let if_false = BoolArray::from_iter( + (0..len).map(|i| (!i.is_multiple_of(5)).then_some(i.is_multiple_of(3))), + ) + .into_array(); + + let bits: Vec = (0..len).map(|i| i.is_multiple_of(2)).collect(); + let mask = Mask::from_iter(bits.iter().copied()); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + assert!(result.is::()); + + let expected = BoolArray::from_iter((0..len).map(|i| { + if bits[i] { + (!i.is_multiple_of(4)).then_some(i.is_multiple_of(2)) + } else { + (!i.is_multiple_of(5)).then_some(i.is_multiple_of(3)) + } + })) + .into_array(); + assert_arrays_eq!(result, expected); + Ok(()) + } +} diff --git a/vortex-array/src/arrays/bool/patch.rs b/vortex-array/src/arrays/bool/patch.rs index fef03bc30fb..5efdac30231 100644 --- a/vortex-array/src/arrays/bool/patch.rs +++ b/vortex-array/src/arrays/bool/patch.rs @@ -31,7 +31,7 @@ impl BoolArray { for (idx, value) in indices .as_slice::() .iter() - .zip_eq(values.to_bit_buffer().iter()) + .zip_eq(values.bit_buffer_view().iter()) { #[allow(clippy::cast_possible_truncation)] own_values.set_to(*idx as usize - offset, value); diff --git a/vortex-array/src/arrays/bool/vtable/kernel.rs b/vortex-array/src/arrays/bool/vtable/kernel.rs index 4f1047a5132..2fe2d974a8f 100644 --- a/vortex-array/src/arrays/bool/vtable/kernel.rs +++ b/vortex-array/src/arrays/bool/vtable/kernel.rs @@ -6,9 +6,11 @@ use crate::arrays::dict::TakeExecuteAdaptor; use crate::kernel::ParentKernelSet; use crate::scalar_fn::fns::cast::CastExecuteAdaptor; use crate::scalar_fn::fns::fill_null::FillNullExecuteAdaptor; +use crate::scalar_fn::fns::zip::ZipExecuteAdaptor; pub(super) const PARENT_KERNELS: ParentKernelSet = ParentKernelSet::new(&[ ParentKernelSet::lift(&CastExecuteAdaptor(Bool)), ParentKernelSet::lift(&FillNullExecuteAdaptor(Bool)), ParentKernelSet::lift(&TakeExecuteAdaptor(Bool)), + ParentKernelSet::lift(&ZipExecuteAdaptor(Bool)), ]); diff --git a/vortex-array/src/arrays/bool/vtable/mod.rs b/vortex-array/src/arrays/bool/vtable/mod.rs index 35261fa8d89..1bbc091b010 100644 --- a/vortex-array/src/arrays/bool/vtable/mod.rs +++ b/vortex-array/src/arrays/bool/vtable/mod.rs @@ -34,7 +34,7 @@ mod validity; use vortex_session::registry::CachedId; -use crate::Precision; +use crate::EqMode; use crate::array::ArrayId; use crate::arrays::bool::compute::rules::RULES; use crate::hash::ArrayEq; @@ -51,15 +51,15 @@ pub struct BoolMetadata { } impl ArrayHash for BoolData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.bits.array_hash(state, precision); - self.offset.hash(state); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.bits.array_hash(state, accuracy); + self.meta.offset().hash(state); } } impl ArrayEq for BoolData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - self.offset == other.offset && self.bits.array_eq(&other.bits, precision) + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { + self.meta.offset() == other.meta.offset() && self.bits.array_eq(&other.bits, accuracy) } } @@ -96,10 +96,11 @@ impl VTable for Bool { array: ArrayView<'_, Self>, _session: &VortexSession, ) -> VortexResult>> { - assert!(array.offset < 8, "Offset must be <8, got {}", array.offset); + let offset = array.meta.offset(); + assert!(offset < 8, "Offset must be <8, got {offset}"); Ok(Some( BoolMetadata { - offset: u32::try_from(array.offset).vortex_expect("checked"), + offset: u32::try_from(offset).vortex_expect("checked"), } .encode_to_vec(), )) @@ -116,9 +117,9 @@ impl VTable for Bool { vortex_bail!("Expected bool dtype, got {dtype:?}"); }; vortex_ensure!( - data.bits.len() * 8 >= data.offset + len, + data.bits.len() * 8 >= data.meta.offset() + len, "BoolArray buffer with offset {} cannot back outer length {} (buffer bits = {})", - data.offset, + data.meta.offset(), len, data.bits.len() * 8 ); diff --git a/vortex-array/src/arrays/bool/vtable/operations.rs b/vortex-array/src/arrays/bool/vtable/operations.rs index 11147b810b2..b4ac4682d23 100644 --- a/vortex-array/src/arrays/bool/vtable/operations.rs +++ b/vortex-array/src/arrays/bool/vtable/operations.rs @@ -17,7 +17,7 @@ impl OperationsVTable for Bool { _ctx: &mut ExecutionCtx, ) -> VortexResult { Ok(Scalar::bool( - array.to_bit_buffer().value(index), + array.bit_buffer_view().value(index), array.dtype().nullability(), )) } diff --git a/vortex-array/src/arrays/chunked/compute/rules.rs b/vortex-array/src/arrays/chunked/compute/rules.rs index d8d324a8e86..712578255bb 100644 --- a/vortex-array/src/arrays/chunked/compute/rules.rs +++ b/vortex-array/src/arrays/chunked/compute/rules.rs @@ -48,13 +48,9 @@ impl ArrayParentReduceRule for ChunkedUnaryScalarFnPushDownRule { let new_chunks: Vec<_> = array .iter_chunks() .map(|chunk| { - ScalarFnArray::try_new( - parent.scalar_fn().clone(), - vec![chunk.clone()], - chunk.len(), - )? - .into_array() - .optimize() + ScalarFnArray::try_new(parent.scalar_fn().clone(), vec![chunk.clone()])? + .into_array() + .optimize() }) .try_collect()?; @@ -104,7 +100,7 @@ impl ArrayParentReduceRule for ChunkedConstantScalarFnPushDownRule { }) .collect(); - ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children, chunk.len())? + ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children)? .into_array() .optimize() }) diff --git a/vortex-array/src/arrays/chunked/vtable/canonical.rs b/vortex-array/src/arrays/chunked/vtable/canonical.rs index 6da34e3cb07..adbe447604b 100644 --- a/vortex-array/src/arrays/chunked/vtable/canonical.rs +++ b/vortex-array/src/arrays/chunked/vtable/canonical.rs @@ -78,7 +78,7 @@ pub(super) fn _canonicalize( }) } -/// Packs many [`StructArray`]s to instead be a single [`StructArray`], where the [`DynArrayData`] for each +/// Packs many [`StructArray`]s to instead be a single [`StructArray`], where the [`DynArrayData`](crate::array::DynArrayData) for each /// field is a [`ChunkedArray`]. /// /// The caller guarantees there are at least 2 chunks. @@ -188,7 +188,7 @@ fn swizzle_list_chunks( let chunk_array = chunk.clone().execute::(ctx)?; // By rebuilding as zero-copy to `List` and trimming all elements (to prevent gaps), we make // the final output `ListView` also zero-copyable to `List`. - let chunk_array = chunk_array.rebuild(ListViewRebuildMode::MakeExact)?; + let chunk_array = chunk_array.rebuild(ListViewRebuildMode::MakeExact, ctx)?; // Add the `elements` of the current array as a new chunk. list_elements_chunks.push(chunk_array.elements().clone()); @@ -285,6 +285,7 @@ fn swizzle_fixed_size_list_chunks( #[cfg(test)] mod tests { use std::sync::Arc; + use std::sync::LazyLock; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -298,15 +299,13 @@ mod tests { use crate::Canonical; use crate::ExecutionCtx; use crate::IntoArray; - use crate::LEGACY_SESSION; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::accessor::ArrayAccessor; use crate::arrays::ChunkedArray; use crate::arrays::ConstantArray; use crate::arrays::FixedSizeListArray; use crate::arrays::ListArray; + use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; use crate::arrays::StructArray; use crate::arrays::VarBinViewArray; @@ -324,8 +323,13 @@ mod tests { use crate::memory::MemorySessionExt; use crate::memory::WritableHostBuffer; use crate::scalar::Scalar; + use crate::session::ArraySession; use crate::validity::Validity; + /// A shared session for these chunked-array tests, used to create execution contexts. + static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[derive(Debug)] struct CountingAllocator { allocations: Arc, @@ -375,7 +379,7 @@ mod tests { fn assert_variant_values(array: &VariantArray, expected: &[i32]) -> VortexResult<()> { assert_eq!(array.len(), expected.len()); - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); for (idx, expected) in expected.iter().copied().enumerate() { let scalar = array.execute_scalar(idx, &mut ctx)?; @@ -391,6 +395,7 @@ mod tests { #[test] fn pack_variant_chunks_without_shredded() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let chunked = ChunkedArray::try_new( vec![ variant_chunk([1, 2])?.into_array(), @@ -400,9 +405,7 @@ mod tests { )? .into_array(); - let variant = into_variant( - chunked.execute::(&mut LEGACY_SESSION.create_execution_ctx())?, - )?; + let variant = into_variant(chunked.execute::(&mut ctx)?)?; assert_eq!(variant.len(), 3); assert!(variant.shredded().is_none()); @@ -411,6 +414,7 @@ mod tests { #[test] fn pack_variant_chunks_all_shredded_same_dtype() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let chunked = ChunkedArray::try_new( vec![ variant_chunk_with_shredded( @@ -425,9 +429,7 @@ mod tests { )? .into_array(); - let variant = into_variant( - chunked.execute::(&mut LEGACY_SESSION.create_execution_ctx())?, - )?; + let variant = into_variant(chunked.execute::(&mut ctx)?)?; let shredded = variant .shredded() .ok_or_else(|| vortex_err!("expected shredded child"))?; @@ -436,15 +438,14 @@ mod tests { assert_eq!(shredded.len(), 3); assert_variant_values(&variant, &[10, 20, 30])?; - let shredded = shredded - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx())?; + let shredded = shredded.clone().execute::(&mut ctx)?; assert_arrays_eq!(shredded, PrimitiveArray::from_iter([10i32, 20, 30])); Ok(()) } #[test] fn pack_variant_chunks_mixed_shredded_presence_errors() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let chunked = ChunkedArray::try_new( vec![ variant_chunk_with_shredded([1], PrimitiveArray::from_iter([10i32]).into_array())? @@ -455,9 +456,7 @@ mod tests { )? .into_array(); - let err = chunked - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap_err(); + let err = chunked.execute::(&mut ctx).unwrap_err(); assert!( err.to_string() .contains("chunks disagree on shredded presence") @@ -467,6 +466,7 @@ mod tests { #[test] fn pack_variant_chunks_mismatched_shredded_dtype_errors() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let chunked = ChunkedArray::try_new( vec![ variant_chunk_with_shredded([1], PrimitiveArray::from_iter([10i32]).into_array())? @@ -478,20 +478,17 @@ mod tests { )? .into_array(); - let err = chunked - .execute::(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap_err(); + let err = chunked.execute::(&mut ctx).unwrap_err(); assert!(err.to_string().contains("shredded dtype mismatch")); Ok(()) } #[test] fn pack_variant_chunks_empty() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let chunked = ChunkedArray::try_new(vec![], VariantDType(NonNullable))?.into_array(); - let variant = into_variant( - chunked.execute::(&mut LEGACY_SESSION.create_execution_ctx())?, - )?; + let variant = into_variant(chunked.execute::(&mut ctx)?)?; assert_eq!(variant.len(), 0); assert!(variant.shredded().is_none()); @@ -500,6 +497,7 @@ mod tests { #[test] fn pack_variant_chunks_single_chunk() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let chunked = ChunkedArray::try_new( vec![ variant_chunk_with_shredded( @@ -512,9 +510,7 @@ mod tests { )? .into_array(); - let variant = into_variant( - chunked.execute::(&mut LEGACY_SESSION.create_execution_ctx())?, - )?; + let variant = into_variant(chunked.execute::(&mut ctx)?)?; assert_eq!(variant.len(), 2); assert!(variant.shredded().is_some()); @@ -523,6 +519,7 @@ mod tests { #[test] pub fn pack_nested_structs() { + let mut ctx = SESSION.create_execution_ctx(); let struct_array = StructArray::try_new( ["a"].into(), vec![VarBinViewArray::from_iter_str(["foo", "bar", "baz", "quak"]).into_array()], @@ -541,12 +538,17 @@ mod tests { ) .unwrap() .into_array(); - #[expect(deprecated)] - let canonical_struct = chunked.to_struct(); - #[expect(deprecated)] - let canonical_varbin = canonical_struct.unmasked_field(0).to_varbinview(); - #[expect(deprecated)] - let original_varbin = struct_array.unmasked_field(0).to_varbinview(); + let canonical_struct = chunked.execute::(&mut ctx).unwrap(); + let canonical_varbin = canonical_struct + .unmasked_field(0) + .clone() + .execute::(&mut ctx) + .unwrap(); + let original_varbin = struct_array + .unmasked_field(0) + .clone() + .execute::(&mut ctx) + .unwrap(); let orig_values = original_varbin .with_iterator(|it| it.map(|a| a.map(|v| v.to_vec())).collect::>()); let canon_values = canonical_varbin @@ -556,6 +558,7 @@ mod tests { #[test] pub fn pack_nested_lists() { + let mut ctx = SESSION.create_execution_ctx(); let l1 = ListArray::try_new( buffer![1, 2, 3, 4].into_array(), buffer![0, 3].into_array(), @@ -575,27 +578,26 @@ mod tests { List(Arc::new(Primitive(I32, NonNullable)), NonNullable), ); - #[expect(deprecated)] - let canon_values = chunked_list.unwrap().as_array().to_listview(); + let canon_values = chunked_list + .unwrap() + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( - l1.execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), - canon_values - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + l1.execute_scalar(0, &mut ctx).unwrap(), + canon_values.execute_scalar(0, &mut ctx).unwrap() ); assert_eq!( - l2.execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), - canon_values - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + l2.execute_scalar(0, &mut ctx).unwrap(), + canon_values.execute_scalar(1, &mut ctx).unwrap() ); } #[test] fn pack_fixed_size_lists() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let f1 = FixedSizeListArray::try_new( buffer![1, 2, 3, 4, 5, 6].into_array(), 2, @@ -613,9 +615,7 @@ mod tests { let chunked = ChunkedArray::try_new(vec![f1.into_array(), f2.into_array()], dtype)?.into_array(); - let canonical = chunked - .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx())?; + let canonical = chunked.clone().execute::(&mut ctx)?; let fsl = match canonical { Canonical::FixedSizeList(fsl) => fsl, other => vortex_bail!("expected FixedSizeList canonical array, got {other:?}"), @@ -630,8 +630,8 @@ mod tests { )?; for idx in 0..5 { assert_eq!( - chunked.execute_scalar(idx, &mut LEGACY_SESSION.create_execution_ctx())?, - expected.execute_scalar(idx, &mut LEGACY_SESSION.create_execution_ctx())?, + chunked.execute_scalar(idx, &mut ctx)?, + expected.execute_scalar(idx, &mut ctx)?, ); } Ok(()) diff --git a/vortex-array/src/arrays/chunked/vtable/mod.rs b/vortex-array/src/arrays/chunked/vtable/mod.rs index 68679a4c3b5..a70ba6869ab 100644 --- a/vortex-array/src/arrays/chunked/vtable/mod.rs +++ b/vortex-array/src/arrays/chunked/vtable/mod.rs @@ -18,10 +18,10 @@ use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; use crate::Canonical; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; use crate::IntoArray; -use crate::Precision; #[expect(deprecated)] use crate::ToCanonical as _; use crate::array::Array; @@ -53,14 +53,14 @@ pub type ChunkedArray = Array; pub struct Chunked; impl ArrayHash for ChunkedData { - fn array_hash(&self, _state: &mut H, _precision: Precision) { + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) { // Chunk offsets are cached derived data. Slot 0 already stores the logical offsets array, // and ArrayData hashing includes every slot before TypedArrayData. } } impl ArrayEq for ChunkedData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { // Chunk offsets are cached derived data. Slot 0 already stores the logical offsets array, // and ArrayData equality compares every slot before TypedArrayData. true diff --git a/vortex-array/src/arrays/constant/vtable/canonical.rs b/vortex-array/src/arrays/constant/vtable/canonical.rs index 53dcd3d262d..dd891778949 100644 --- a/vortex-array/src/arrays/constant/vtable/canonical.rs +++ b/vortex-array/src/arrays/constant/vtable/canonical.rs @@ -330,25 +330,30 @@ fn constant_canonical_fixed_size_list_array( #[cfg(test)] mod tests { use std::sync::Arc; + use std::sync::LazyLock; use enum_iterator::all; use itertools::Itertools; use vortex_error::VortexExpect; use vortex_error::VortexResult; + use vortex_session::VortexSession; + use crate::Canonical; use crate::IntoArray; - use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::ConstantArray; + use crate::arrays::FixedSizeListArray; + use crate::arrays::ListViewArray; + use crate::arrays::NullArray; use crate::arrays::PrimitiveArray; + use crate::arrays::StructArray; use crate::arrays::VarBinArray; + use crate::arrays::VarBinViewArray; use crate::arrays::fixed_size_list::FixedSizeListArrayExt; use crate::arrays::listview::ListViewArrayExt; use crate::arrays::listview::ListViewRebuildMode; use crate::arrays::struct_::StructArrayExt; use crate::assert_arrays_eq; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::PType; @@ -356,18 +361,25 @@ mod tests { use crate::expr::stats::Stat; use crate::expr::stats::StatsProvider; use crate::scalar::Scalar; + use crate::session::ArraySession; use crate::validity::Validity; + /// A shared session for these constant-array tests, used to create execution contexts. + static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[test] fn test_canonicalize_null() { + let mut ctx = SESSION.create_execution_ctx(); let const_null = ConstantArray::new(Scalar::null(DType::Null), 42); - #[expect(deprecated)] - let actual = const_null.as_array().to_null(); + let actual = const_null + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(actual.len(), 42); assert_eq!( - actual - .execute_scalar(33, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + actual.execute_scalar(33, &mut ctx).unwrap(), Scalar::null(DType::Null) ); } @@ -382,14 +394,13 @@ mod tests { #[test] fn test_canonicalize_propagates_stats() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let scalar = Scalar::bool(true, Nullability::NonNullable); let const_array = ConstantArray::new(scalar, 4).into_array(); - let stats = const_array.statistics().compute_all( - &all::().collect_vec(), - &mut LEGACY_SESSION.create_execution_ctx(), - )?; - #[expect(deprecated)] - let canonical = const_array.to_canonical()?.into_array(); + let stats = const_array + .statistics() + .compute_all(&all::().collect_vec(), &mut ctx)?; + let canonical = const_array.execute::(&mut ctx)?.into_array(); let canonical_stats = canonical.statistics(); let stats_ref = stats.as_typed_ref(canonical.dtype()); @@ -409,47 +420,52 @@ mod tests { #[test] fn test_canonicalize_scalar_values() { + let mut ctx = SESSION.create_execution_ctx(); let f16_value = f16::from_f32(5.722046e-6); let f16_scalar = Scalar::primitive(f16_value, Nullability::NonNullable); // Create a ConstantArray with the f16 scalar let const_array = ConstantArray::new(f16_scalar.clone(), 1).into_array(); - #[expect(deprecated)] - let canonical_const = const_array.to_primitive(); + let canonical_const = const_array.execute::(&mut ctx).unwrap(); // Verify the scalar value is preserved through canonicalization assert_eq!( - canonical_const - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + canonical_const.execute_scalar(0, &mut ctx).unwrap(), f16_scalar ); } #[test] fn test_canonicalize_lists() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let list_scalar = Scalar::list( Arc::new(DType::Primitive(PType::U64, Nullability::NonNullable)), vec![1u64.into(), 2u64.into()], Nullability::NonNullable, ); let const_array = ConstantArray::new(list_scalar, 2).into_array(); - #[expect(deprecated)] - let canonical_const = const_array.to_listview(); - let list_array = canonical_const.rebuild(ListViewRebuildMode::MakeZeroCopyToList)?; + let canonical_const = const_array.execute::(&mut ctx)?; + let list_array = + canonical_const.rebuild(ListViewRebuildMode::MakeZeroCopyToList, &mut ctx)?; assert_arrays_eq!( - #[expect(deprecated)] - list_array.elements().to_primitive(), + list_array + .elements() + .clone() + .execute::(&mut ctx)?, PrimitiveArray::from_iter([1u64, 2, 1, 2]) ); assert_arrays_eq!( - #[expect(deprecated)] - list_array.offsets().to_primitive(), + list_array + .offsets() + .clone() + .execute::(&mut ctx)?, PrimitiveArray::from_iter([0u64, 2]) ); assert_arrays_eq!( - #[expect(deprecated)] - list_array.sizes().to_primitive(), + list_array + .sizes() + .clone() + .execute::(&mut ctx)?, PrimitiveArray::from_iter([2u64, 2]) ); Ok(()) @@ -457,55 +473,74 @@ mod tests { #[test] fn test_canonicalize_empty_list() { + let mut ctx = SESSION.create_execution_ctx(); let list_scalar = Scalar::list( Arc::new(DType::Primitive(PType::U64, Nullability::NonNullable)), vec![], Nullability::NonNullable, ); let const_array = ConstantArray::new(list_scalar, 2).into_array(); - #[expect(deprecated)] - let canonical_const = const_array.to_listview(); - #[expect(deprecated)] - let elements_prim = canonical_const.elements().to_primitive(); + let canonical_const = const_array.execute::(&mut ctx).unwrap(); + let elements_prim = canonical_const + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert!(elements_prim.is_empty()); assert_arrays_eq!( - #[expect(deprecated)] - canonical_const.offsets().to_primitive(), + canonical_const + .offsets() + .clone() + .execute::(&mut ctx) + .unwrap(), PrimitiveArray::from_iter([0u64, 0]) ); assert_arrays_eq!( - #[expect(deprecated)] - canonical_const.sizes().to_primitive(), + canonical_const + .sizes() + .clone() + .execute::(&mut ctx) + .unwrap(), PrimitiveArray::from_iter([0u64, 0]) ); } #[test] fn test_canonicalize_null_list() { + let mut ctx = SESSION.create_execution_ctx(); let list_scalar = Scalar::null(DType::List( Arc::new(DType::Primitive(PType::U64, Nullability::NonNullable)), Nullability::Nullable, )); let const_array = ConstantArray::new(list_scalar, 2).into_array(); - #[expect(deprecated)] - let canonical_const = const_array.to_listview(); - #[expect(deprecated)] - let elements_prim = canonical_const.elements().to_primitive(); + let canonical_const = const_array.execute::(&mut ctx).unwrap(); + let elements_prim = canonical_const + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert!(elements_prim.is_empty()); assert_arrays_eq!( - #[expect(deprecated)] - canonical_const.offsets().to_primitive(), + canonical_const + .offsets() + .clone() + .execute::(&mut ctx) + .unwrap(), PrimitiveArray::from_iter([0u64, 0]) ); assert_arrays_eq!( - #[expect(deprecated)] - canonical_const.sizes().to_primitive(), + canonical_const + .sizes() + .clone() + .execute::(&mut ctx) + .unwrap(), PrimitiveArray::from_iter([0u64, 0]) ); } #[test] fn test_canonicalize_nullable_struct() { + let mut ctx = SESSION.create_execution_ctx(); let array = ConstantArray::new( Scalar::null(DType::struct_( [( @@ -517,15 +552,13 @@ mod tests { 3, ); - #[expect(deprecated)] - let struct_array = array.as_array().to_struct(); + let struct_array = array + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(struct_array.len(), 3); - assert_eq!( - struct_array - .valid_count(&mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), - 0 - ); + assert_eq!(struct_array.valid_count(&mut ctx).unwrap(), 0); let field = struct_array .unmasked_field_by_name("non_null_field") @@ -539,6 +572,7 @@ mod tests { #[test] fn test_canonicalize_fixed_size_list_non_null() { + let mut ctx = SESSION.create_execution_ctx(); // Test with a non-null fixed-size list constant. let fsl_scalar = Scalar::fixed_size_list( Arc::new(DType::Primitive(PType::I32, Nullability::NonNullable)), @@ -551,8 +585,7 @@ mod tests { ); let const_array = ConstantArray::new(fsl_scalar, 4).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 4); assert_eq!(canonical.list_size(), 3); @@ -561,14 +594,14 @@ mod tests { // Check that each list is [10, 20, 30]. for i in 0..4 { let list = canonical.fixed_size_list_elements_at(i).unwrap(); - #[expect(deprecated)] - let list_primitive = list.to_primitive(); + let list_primitive = list.execute::(&mut ctx).unwrap(); assert_arrays_eq!(list_primitive, PrimitiveArray::from_iter([10i32, 20, 30])); } } #[test] fn test_canonicalize_fixed_size_list_nullable() { + let mut ctx = SESSION.create_execution_ctx(); // Test with a nullable but non-null fixed-size list constant. let fsl_scalar = Scalar::fixed_size_list( Arc::new(DType::Primitive(PType::F64, Nullability::NonNullable)), @@ -580,16 +613,18 @@ mod tests { ); let const_array = ConstantArray::new(fsl_scalar, 3).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 3); assert_eq!(canonical.list_size(), 2); assert!(matches!(canonical.validity(), Ok(Validity::AllValid))); // Check elements. - #[expect(deprecated)] - let elements = canonical.elements().to_primitive(); + let elements = canonical + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( elements, PrimitiveArray::from_iter([1.5f64, 2.5, 1.5, 2.5, 1.5, 2.5]) @@ -598,6 +633,7 @@ mod tests { #[test] fn test_canonicalize_fixed_size_list_null() { + let mut ctx = SESSION.create_execution_ctx(); // Test with a null fixed-size list constant. let fsl_scalar = Scalar::null(DType::FixedSizeList( Arc::new(DType::Primitive(PType::U64, Nullability::NonNullable)), @@ -606,22 +642,25 @@ mod tests { )); let const_array = ConstantArray::new(fsl_scalar, 5).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 5); assert_eq!(canonical.list_size(), 4); assert!(matches!(canonical.validity(), Ok(Validity::AllInvalid))); // Elements should be defaults (zeros). - #[expect(deprecated)] - let elements = canonical.elements().to_primitive(); + let elements = canonical + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(elements.len(), 20); // 5 lists * 4 elements each assert!(elements.as_slice::().iter().all(|&x| x == 0)); } #[test] fn test_canonicalize_fixed_size_list_empty() { + let mut ctx = SESSION.create_execution_ctx(); // Test with size-0 lists (edge case). let fsl_scalar = Scalar::fixed_size_list( Arc::new(DType::Primitive(PType::I8, Nullability::NonNullable)), @@ -630,8 +669,7 @@ mod tests { ); let const_array = ConstantArray::new(fsl_scalar, 10).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 10); assert_eq!(canonical.list_size(), 0); @@ -643,6 +681,7 @@ mod tests { #[test] fn test_canonicalize_fixed_size_list_nested() { + let mut ctx = SESSION.create_execution_ctx(); // Test with nested data types (list of strings). let fsl_scalar = Scalar::fixed_size_list( Arc::new(DType::Utf8(Nullability::NonNullable)), @@ -651,43 +690,38 @@ mod tests { ); let const_array = ConstantArray::new(fsl_scalar, 2).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 2); assert_eq!(canonical.list_size(), 2); // Check elements are repeated correctly. - #[expect(deprecated)] - let elements = canonical.elements().to_varbinview(); + let elements = canonical + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( - elements - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(0, &mut ctx).unwrap(), "hello".into() ); assert_eq!( - elements - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(1, &mut ctx).unwrap(), "world".into() ); assert_eq!( - elements - .execute_scalar(2, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(2, &mut ctx).unwrap(), "hello".into() ); assert_eq!( - elements - .execute_scalar(3, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(3, &mut ctx).unwrap(), "world".into() ); } #[test] fn test_canonicalize_fixed_size_list_single_element() { + let mut ctx = SESSION.create_execution_ctx(); // Test with a single-element list. let fsl_scalar = Scalar::fixed_size_list( Arc::new(DType::Primitive(PType::I16, Nullability::NonNullable)), @@ -696,19 +730,22 @@ mod tests { ); let const_array = ConstantArray::new(fsl_scalar, 1).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 1); assert_eq!(canonical.list_size(), 1); - #[expect(deprecated)] - let elements = canonical.elements().to_primitive(); + let elements = canonical + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!(elements, PrimitiveArray::from_iter([42i16])); } #[test] fn test_canonicalize_fixed_size_list_with_null_elements() { + let mut ctx = SESSION.create_execution_ctx(); // Test FSL with nullable element type where some elements are null. let fsl_scalar = Scalar::fixed_size_list( Arc::new(DType::Primitive(PType::I32, Nullability::Nullable)), @@ -721,32 +758,28 @@ mod tests { ); let const_array = ConstantArray::new(fsl_scalar, 3).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 3); assert_eq!(canonical.list_size(), 3); assert!(matches!(canonical.validity(), Ok(Validity::NonNullable))); // Check elements including nulls. - #[expect(deprecated)] - let elements = canonical.elements().to_primitive(); + let elements = canonical + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( - elements - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(0, &mut ctx).unwrap(), Scalar::from(100i32) ); assert_eq!( - elements - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(1, &mut ctx).unwrap(), Scalar::null(DType::Primitive(PType::I32, Nullability::Nullable)) ); assert_eq!( - elements - .execute_scalar(2, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(2, &mut ctx).unwrap(), Scalar::from(200i32) ); @@ -754,18 +787,19 @@ mod tests { let element_validity = elements .validity() .vortex_expect("constant canonical element validity should be derivable"); - assert!(element_validity.is_valid(0).unwrap()); - assert!(!element_validity.is_valid(1).unwrap()); - assert!(element_validity.is_valid(2).unwrap()); + assert!(element_validity.execute_is_valid(0, &mut ctx).unwrap()); + assert!(!element_validity.execute_is_valid(1, &mut ctx).unwrap()); + assert!(element_validity.execute_is_valid(2, &mut ctx).unwrap()); // Pattern should repeat. - assert!(element_validity.is_valid(3).unwrap()); - assert!(!element_validity.is_valid(4).unwrap()); - assert!(element_validity.is_valid(5).unwrap()); + assert!(element_validity.execute_is_valid(3, &mut ctx).unwrap()); + assert!(!element_validity.execute_is_valid(4, &mut ctx).unwrap()); + assert!(element_validity.execute_is_valid(5, &mut ctx).unwrap()); } #[test] fn test_canonicalize_fixed_size_list_large() { + let mut ctx = SESSION.create_execution_ctx(); // Test with a large constant array. let fsl_scalar = Scalar::fixed_size_list( Arc::new(DType::Primitive(PType::U8, Nullability::NonNullable)), @@ -780,47 +814,39 @@ mod tests { ); let const_array = ConstantArray::new(fsl_scalar, 1000).into_array(); - #[expect(deprecated)] - let canonical = const_array.to_fixed_size_list(); + let canonical = const_array.execute::(&mut ctx).unwrap(); assert_eq!(canonical.len(), 1000); assert_eq!(canonical.list_size(), 5); - #[expect(deprecated)] - let elements = canonical.elements().to_primitive(); + let elements = canonical + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(elements.len(), 5000); // Check pattern repeats correctly. for i in 0..1000 { let base = i * 5; assert_eq!( - elements - .execute_scalar(base, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(base, &mut ctx).unwrap(), Scalar::from(1u8) ); assert_eq!( - elements - .execute_scalar(base + 1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(base + 1, &mut ctx).unwrap(), Scalar::from(2u8) ); assert_eq!( - elements - .execute_scalar(base + 2, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(base + 2, &mut ctx).unwrap(), Scalar::from(3u8) ); assert_eq!( - elements - .execute_scalar(base + 3, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(base + 3, &mut ctx).unwrap(), Scalar::from(4u8) ); assert_eq!( - elements - .execute_scalar(base + 4, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), + elements.execute_scalar(base + 4, &mut ctx).unwrap(), Scalar::from(5u8) ); } diff --git a/vortex-array/src/arrays/constant/vtable/mod.rs b/vortex-array/src/arrays/constant/vtable/mod.rs index c130c28a95a..89c571eb827 100644 --- a/vortex-array/src/arrays/constant/vtable/mod.rs +++ b/vortex-array/src/arrays/constant/vtable/mod.rs @@ -16,10 +16,10 @@ use vortex_session::registry::CachedId; use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; use crate::IntoArray; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayView; @@ -53,13 +53,13 @@ pub type ConstantArray = Array; pub struct Constant; impl ArrayHash for ConstantData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.scalar.hash(state); } } impl ArrayEq for ConstantData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.scalar == other.scalar } } diff --git a/vortex-array/src/arrays/datetime/test.rs b/vortex-array/src/arrays/datetime/test.rs index 2b1a681e7fb..511b8196a79 100644 --- a/vortex-array/src/arrays/datetime/test.rs +++ b/vortex-array/src/arrays/datetime/test.rs @@ -6,8 +6,8 @@ use vortex_buffer::buffer; use vortex_error::VortexExpect; use vortex_error::VortexResult; +use crate::EqMode; use crate::IntoArray; -use crate::Precision; #[expect(deprecated)] use crate::ToCanonical as _; use crate::arrays::PrimitiveArray; @@ -201,7 +201,7 @@ fn test_validity_preservation(#[case] validity: Validity) { assert!( prim.validity() .vortex_expect("temporal validity should be derivable") - .array_eq(&validity, Precision::Ptr) + .array_eq(&validity, EqMode::Ptr) ); } diff --git a/vortex-array/src/arrays/decimal/vtable/mod.rs b/vortex-array/src/arrays/decimal/vtable/mod.rs index dac24ecd95f..74e76ab4f51 100644 --- a/vortex-array/src/arrays/decimal/vtable/mod.rs +++ b/vortex-array/src/arrays/decimal/vtable/mod.rs @@ -34,7 +34,7 @@ use std::hash::Hash; use vortex_session::registry::CachedId; -use crate::Precision; +use crate::EqMode; use crate::array::ArrayId; use crate::arrays::decimal::array::SLOT_NAMES; use crate::arrays::decimal::compute::rules::RULES; @@ -51,15 +51,15 @@ pub struct DecimalMetadata { } impl ArrayHash for DecimalData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.values.array_hash(state, precision); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.values.array_hash(state, accuracy); std::mem::discriminant(&self.values_type).hash(state); } } impl ArrayEq for DecimalData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - self.values.array_eq(&other.values, precision) && self.values_type == other.values_type + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { + self.values.array_eq(&other.values, accuracy) && self.values_type == other.values_type } } diff --git a/vortex-array/src/arrays/dict/array.rs b/vortex-array/src/arrays/dict/array.rs index 10108191744..59758924a19 100644 --- a/vortex-array/src/arrays/dict/array.rs +++ b/vortex-array/src/arrays/dict/array.rs @@ -135,7 +135,7 @@ pub trait DictArrayExt: TypedArrayRef + DictArraySlotsExt { } let referenced_mask = self.compute_referenced_values_mask(true)?; - let all_referenced = referenced_mask.iter().all(|v| v); + let all_referenced = referenced_mask.true_count() == referenced_mask.len(); vortex_ensure!(all_referenced, "value in dict not referenced"); } diff --git a/vortex-array/src/arrays/dict/compute/cast.rs b/vortex-array/src/arrays/dict/compute/cast.rs index c269b214e19..490f1f31549 100644 --- a/vortex-array/src/arrays/dict/compute/cast.rs +++ b/vortex-array/src/arrays/dict/compute/cast.rs @@ -18,7 +18,7 @@ impl CastReduce for Dict { fn cast(array: ArrayView<'_, Dict>, dtype: &DType) -> VortexResult> { // Can have un-reference null values making the cast of values fail without a possible mask. // TODO(joe): optimize this, could look at accessible values and fill_null not those? - if !dtype.is_nullable() && !array.values().validity()?.no_nulls() { + if !dtype.is_nullable() && !array.values().validity()?.definitely_no_nulls() { return Ok(None); } // Cast the dictionary values to the target type diff --git a/vortex-array/src/arrays/dict/compute/fill_null.rs b/vortex-array/src/arrays/dict/compute/fill_null.rs index 189d8240050..8f146d728a3 100644 --- a/vortex-array/src/arrays/dict/compute/fill_null.rs +++ b/vortex-array/src/arrays/dict/compute/fill_null.rs @@ -41,7 +41,7 @@ impl FillNullKernel for Dict { // We found the fill value already in the values at this given index. let Some(existing_fill_value_index) = - found_fill_values.to_bit_buffer().set_indices().next() + found_fill_values.bit_buffer_view().set_indices().next() else { // No fill values found, so we must canonicalize and fill_null. return Ok(Some( diff --git a/vortex-array/src/arrays/dict/compute/rules.rs b/vortex-array/src/arrays/dict/compute/rules.rs index f6fe816a6cc..024240e4030 100644 --- a/vortex-array/src/arrays/dict/compute/rules.rs +++ b/vortex-array/src/arrays/dict/compute/rules.rs @@ -5,8 +5,8 @@ use vortex_error::VortexResult; use crate::ArrayEq; use crate::ArrayRef; +use crate::EqMode; use crate::IntoArray; -use crate::Precision; use crate::array::ArrayView; use crate::array::VTable; use crate::arrays::Constant; @@ -126,10 +126,9 @@ impl ArrayParentReduceRule for DictionaryScalarFnValuesPushDownRule { } } - let new_values = - ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children, values_len)? - .into_array() - .optimize()?; + let new_values = ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children)? + .into_array() + .optimize()?; // We can only push down null-sensitive functions when we have all-valid codes. // In these cases, we cannot have the codes influence the nullability of the output DType. @@ -175,11 +174,10 @@ impl ArrayParentReduceRule for DictionaryScalarFnCodesPullUpRule { } // Now run the slightly more expensive check that all siblings have the same codes as us. - // We use the cheaper Precision::Ptr to avoid doing data comparisons. if !parent.iter_children().enumerate().all(|(idx, c)| { idx == child_idx || c.as_opt::() - .is_some_and(|c| c.codes().array_eq(array.codes(), Precision::Value)) + .is_some_and(|c| c.codes().array_eq(array.codes(), EqMode::Value)) }) { return Ok(None); } @@ -193,13 +191,9 @@ impl ArrayParentReduceRule for DictionaryScalarFnCodesPullUpRule { } } - let new_values = ScalarFnArray::try_new( - parent.scalar_fn().clone(), - new_children, - array.values().len(), - )? - .into_array() - .optimize()?; + let new_values = ScalarFnArray::try_new(parent.scalar_fn().clone(), new_children)? + .into_array() + .optimize()?; let new_dict = unsafe { DictArray::new_unchecked(array.codes().clone(), new_values) }.into_array(); diff --git a/vortex-array/src/arrays/dict/compute/slice.rs b/vortex-array/src/arrays/dict/compute/slice.rs index 509fa7535d8..1552c9c9fea 100644 --- a/vortex-array/src/arrays/dict/compute/slice.rs +++ b/vortex-array/src/arrays/dict/compute/slice.rs @@ -3,6 +3,7 @@ use std::ops::Range; +use vortex_error::VortexExpect; use vortex_error::VortexResult; use crate::ArrayRef; @@ -12,35 +13,74 @@ use crate::arrays::Constant; use crate::arrays::ConstantArray; use crate::arrays::Dict; use crate::arrays::DictArray; +use crate::arrays::Primitive; use crate::arrays::dict::DictArraySlotsExt; use crate::arrays::slice::SliceReduce; +use crate::expr::stats::Precision; +use crate::expr::stats::Stat; use crate::scalar::Scalar; +use crate::scalar::ScalarValue; impl SliceReduce for Dict { fn slice(array: ArrayView<'_, Self>, range: Range) -> VortexResult> { - let sliced_code = array.codes().slice(range)?; + if let Some(code) = array.codes().as_opt::() { + return slice_constant_code(array, code.scalar(), range.len()); + } + + let sliced_code = if let Some(codes) = array.codes().as_typed::() { + let sliced_code = ::slice(codes, range)? + .vortex_expect("Primitive SliceReduce should always return Some"); + // Because we specialize the primitive branch here, we have to make sure to handle the stat inheritance + inherit_slice_stats(array.codes(), &sliced_code); + sliced_code + } else { + array.codes().slice(range)? + }; + // TODO(joe): if the range is size 1 replace with a constant array if let Some(code) = sliced_code.as_opt::() { - let code = code.scalar().as_primitive().as_::(); - return if let Some(code) = code { - let values = array.values().slice(code..code + 1)?; - Ok(Some( - DictArray::new( - ConstantArray::new(0u8, sliced_code.len()).into_array(), - values, - ) - .into_array(), - )) - } else { - Ok(Some( - ConstantArray::new(Scalar::null(array.dtype().clone()), sliced_code.len()) - .into_array(), - )) - }; + return slice_constant_code(array, code.scalar(), sliced_code.len()); } // SAFETY: slicing the codes preserves invariants. + let array = + unsafe { DictArray::new_unchecked(sliced_code, array.values().clone()).into_array() }; + + Ok(Some(array)) + } +} + +fn inherit_slice_stats(source: &ArrayRef, sliced: &ArrayRef) { + source.statistics().with_iter(|iter| { + sliced + .statistics() + .inherit(iter.filter(|(stat, value)| is_inheritable_true_slice_stat(*stat, value))); + }); +} + +fn is_inheritable_true_slice_stat(stat: Stat, value: &Precision) -> bool { + matches!( + stat, + Stat::IsConstant | Stat::IsSorted | Stat::IsStrictSorted + ) && value + .as_ref() + .as_exact() + .is_some_and(|value| matches!(value, ScalarValue::Bool(true))) +} + +fn slice_constant_code( + array: ArrayView<'_, Dict>, + code: &Scalar, + len: usize, +) -> VortexResult> { + let code = code.as_primitive().as_::(); + if let Some(code) = code { + let values = array.values().slice(code..code + 1)?; + Ok(Some( + DictArray::new(ConstantArray::new(0u8, len).into_array(), values).into_array(), + )) + } else { Ok(Some( - unsafe { DictArray::new_unchecked(sliced_code, array.values().clone()) }.into_array(), + ConstantArray::new(Scalar::null(array.dtype().clone()), len).into_array(), )) } } diff --git a/vortex-array/src/arrays/dict/vtable/mod.rs b/vortex-array/src/arrays/dict/vtable/mod.rs index 33db223de72..fa8515dd986 100644 --- a/vortex-array/src/arrays/dict/vtable/mod.rs +++ b/vortex-array/src/arrays/dict/vtable/mod.rs @@ -25,7 +25,7 @@ use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; use crate::Canonical; -use crate::Precision; +use crate::EqMode; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayParts; @@ -59,11 +59,11 @@ pub type DictArray = Array; pub struct Dict; impl ArrayHash for DictData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl ArrayEq for DictData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } diff --git a/vortex-array/src/arrays/extension/compute/rules.rs b/vortex-array/src/arrays/extension/compute/rules.rs index d7c8469d1dc..59a8c314517 100644 --- a/vortex-array/src/arrays/extension/compute/rules.rs +++ b/vortex-array/src/arrays/extension/compute/rules.rs @@ -85,6 +85,7 @@ mod tests { use vortex_mask::Mask; use vortex_session::VortexSession; + use crate::EmptyMetadata; use crate::IntoArray; #[expect(deprecated)] use crate::ToCanonical as _; @@ -105,7 +106,6 @@ mod tests { use crate::dtype::extension::ExtDTypeRef; use crate::dtype::extension::ExtId; use crate::dtype::extension::ExtVTable; - use crate::extension::EmptyMetadata; use crate::optimizer::ArrayOptimizer; use crate::scalar::Scalar; use crate::scalar::ScalarValue; diff --git a/vortex-array/src/arrays/filter/execute/mod.rs b/vortex-array/src/arrays/filter/execute/mod.rs index e1db2471780..52b0373ac49 100644 --- a/vortex-array/src/arrays/filter/execute/mod.rs +++ b/vortex-array/src/arrays/filter/execute/mod.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! Execution logic for [`FilterArray`]. +//! Execution logic for [`super::FilterArray`]. //! //! The main entrypoint is [`execute_filter`] which filters any [`Canonical`] array. diff --git a/vortex-array/src/arrays/filter/execute/take/fixed_width.rs b/vortex-array/src/arrays/filter/execute/take/fixed_width.rs index edafbeb9abd..3e508abd2c5 100644 --- a/vortex-array/src/arrays/filter/execute/take/fixed_width.rs +++ b/vortex-array/src/arrays/filter/execute/take/fixed_width.rs @@ -105,7 +105,7 @@ where take_filtered_values::(&child, filter, ranks, None)? }; - let output_validity = if child_validity.no_nulls() { + let output_validity = if child_validity.definitely_no_nulls() { ranks_validity.union_nullability(child_validity.nullability()) } else { let translated_indices = @@ -120,7 +120,7 @@ where AllOr::Some(buf) => { let taken = take_filtered_values(child.as_slice(), filter, ranks, Some(buf))?; - let output_validity = if child_validity.no_nulls() { + let output_validity = if child_validity.definitely_no_nulls() { ranks_validity.union_nullability(child_validity.nullability()) } else { let translated_indices = diff --git a/vortex-array/src/arrays/filter/vtable.rs b/vortex-array/src/arrays/filter/vtable.rs index 2361c541997..5be6bc6ac35 100644 --- a/vortex-array/src/arrays/filter/vtable.rs +++ b/vortex-array/src/arrays/filter/vtable.rs @@ -18,8 +18,8 @@ use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; use crate::Canonical; +use crate::EqMode; use crate::IntoArray; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayView; @@ -51,14 +51,14 @@ pub type FilterArray = Array; pub struct Filter; impl ArrayHash for FilterData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.mask.array_hash(state, precision); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.mask.array_hash(state, accuracy); } } impl ArrayEq for FilterData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - self.mask.array_eq(&other.mask, precision) + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { + self.mask.array_eq(&other.mask, accuracy) } } diff --git a/vortex-array/src/arrays/fixed_size_list/array.rs b/vortex-array/src/arrays/fixed_size_list/array.rs index cc82551ab82..f24001c4e78 100644 --- a/vortex-array/src/arrays/fixed_size_list/array.rs +++ b/vortex-array/src/arrays/fixed_size_list/array.rs @@ -12,6 +12,8 @@ use vortex_error::vortex_ensure; use crate::ArrayRef; use crate::ArraySlots; +use crate::LEGACY_SESSION; +use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; use crate::array::TypedArrayRef; @@ -236,11 +238,14 @@ pub trait FixedSizeListArrayExt: TypedArrayRef { index, self.as_ref().len(), ); - debug_assert!( - self.fixed_size_list_validity() - .is_valid(index) - .unwrap_or(false) - ); + #[expect(clippy::debug_assert_with_mut_call)] + { + debug_assert!( + self.fixed_size_list_validity() + .execute_is_valid(index, &mut LEGACY_SESSION.create_execution_ctx()) + .unwrap_or(false) + ); + } let start = self.list_size() as usize * index; let end = self.list_size() as usize * (index + 1); diff --git a/vortex-array/src/arrays/fixed_size_list/compute/take.rs b/vortex-array/src/arrays/fixed_size_list/compute/take.rs index 8df3bae0b0c..193730f62c4 100644 --- a/vortex-array/src/arrays/fixed_size_list/compute/take.rs +++ b/vortex-array/src/arrays/fixed_size_list/compute/take.rs @@ -175,13 +175,11 @@ fn take_nullable_fsl( let mut new_validity_builder = BitBufferMut::with_capacity(new_len); // Build the element indices while tracking which lists are null. - for (i, data_idx) in indices.iter().enumerate() { + for (data_idx, is_index_valid) in indices.iter().zip(indices_validity.iter()) { let data_idx = data_idx .to_usize() .unwrap_or_else(|| vortex_panic!("Failed to convert index to usize: {}", data_idx)); - let is_index_valid = indices_validity.value(i); - // The list is null if the index is null or the indexed element is null. if !is_index_valid || !array_validity.value(data_idx) { // Append placeholder zeros for null lists. These will be masked by the validity array. diff --git a/vortex-array/src/arrays/fixed_size_list/tests/nested.rs b/vortex-array/src/arrays/fixed_size_list/tests/nested.rs index efd6f630580..8fca62c0097 100644 --- a/vortex-array/src/arrays/fixed_size_list/tests/nested.rs +++ b/vortex-array/src/arrays/fixed_size_list/tests/nested.rs @@ -270,7 +270,6 @@ fn test_fsl_of_fsl_with_nulls() { #[test] fn test_deeply_nested_fsl() { - let _len = 2; let list_size = 2; // Create a 3-level nested FSL: FSL[FSL[FSL[i32]]]. diff --git a/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs b/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs index ca38f3b777b..8d86e96a174 100644 --- a/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs +++ b/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs @@ -16,9 +16,9 @@ use vortex_session::registry::CachedId; use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayView; @@ -43,15 +43,13 @@ pub type FixedSizeListArray = Array; pub struct FixedSizeList; impl ArrayHash for FixedSizeListData { - fn array_hash(&self, state: &mut H, precision: Precision) { - let _precision = precision; + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.degenerate_len.hash(state); } } impl ArrayEq for FixedSizeListData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - let _precision = precision; + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.degenerate_len == other.degenerate_len } } diff --git a/vortex-array/src/arrays/interleave/execute/bool.rs b/vortex-array/src/arrays/interleave/execute/bool.rs new file mode 100644 index 00000000000..755921abd59 --- /dev/null +++ b/vortex-array/src/arrays/interleave/execute/bool.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Optimized [`Interleave`] implementation for boolean values. + +use num_traits::AsPrimitive; +use vortex_buffer::BitBuffer; +use vortex_buffer::BitBufferMut; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure; +use vortex_mask::Mask; + +use super::super::Interleave; +use super::super::InterleaveArrayExt; +use crate::array::Array; +use crate::arrays::Bool; +use crate::arrays::BoolArray; +use crate::arrays::Primitive; +use crate::arrays::bool::BoolArrayExt; +use crate::executor::ExecutionCtx; +use crate::executor::ExecutionResult; +use crate::match_each_unsigned_integer_ptype; +use crate::require_child; +use crate::validity::Validity; + +/// Gathers `N` boolean values under unsigned `array_indices` / `row_indices` selectors, scattering +/// each selected bit (and its validity) into the output position it routes to. +pub(super) fn execute( + array: Array, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let num_values = array.num_values(); + + // Drive every value and both selectors to canonical encodings so we can operate on raw bits. + let mut array = array; + for i in 0..num_values { + array = require_child!(array, array.value(i), i => Bool); + } + array = require_child!(array, array.array_indices(), num_values => Primitive); + array = require_child!(array, array.row_indices(), num_values + 1 => Primitive); + + let dtype = array.as_ref().dtype().clone(); + let len = array.as_ref().len(); + let nullable = dtype.is_nullable(); + + // Materialize each value's bits, and its validity mask only when the output can be null. + let mut value_bits = Vec::with_capacity(num_values); + let mut value_validity = Vec::with_capacity(num_values); + for i in 0..num_values { + let value = array.value(i).as_::(); + let bits = value.to_bit_buffer(); + let validity = nullable + .then(|| value.validity()?.execute_mask(bits.len(), ctx)) + .transpose()?; + value_bits.push(bits); + value_validity.push(validity); + } + + // Scatter directly from the typed selector buffers — no intermediate `usize` materialization. + let array_indices = array.array_indices().as_::(); + let row_indices = array.row_indices().as_::(); + let (values, validity) = match_each_unsigned_integer_ptype!(array_indices.ptype(), |A| { + match_each_unsigned_integer_ptype!(row_indices.ptype(), |R| { + gather( + len, + num_values, + &value_bits, + &value_validity, + array_indices.as_slice::(), + row_indices.as_slice::(), + nullable, + )? + }) + }); + + let validity = match validity { + Some(bits) => Validity::from(bits.freeze()), + None => Validity::NonNullable, + }; + Ok(ExecutionResult::done(BoolArray::try_new( + values.freeze(), + validity, + )?)) +} + +/// The scatter, monomorphized on the selector integer widths so each `(array_index, row_index)` +/// pair is read straight from its packed buffer. +/// +/// Output bits (and validity) are produced with [`BitBufferMut::collect_bool`], which packs 64 +/// results per word: every output bit is written branchlessly, avoiding a per-row `set`/`unset` +/// (each of which would bounds-check and branch on the random bit value). +#[allow(clippy::too_many_arguments)] +fn gather, R: AsPrimitive>( + len: usize, + num_values: usize, + value_bits: &[BitBuffer], + value_validity: &[Option], + branches: &[A], + rows: &[R], + nullable: bool, +) -> VortexResult<(BitBufferMut, Option)> { + // Validate the per-row bounds once up front (returning an error rather than panicking), so the + // word-packing passes below are tight branchless loops. + for i in 0..len { + let branch = branches[i].as_(); + vortex_ensure!(branch < num_values, "interleave array index out of bounds"); + vortex_ensure!( + rows[i].as_() < value_bits[branch].len(), + "interleave row index out of bounds" + ); + } + + let values = + BitBufferMut::collect_bool(len, |i| value_bits[branches[i].as_()].value(rows[i].as_())); + + // A missing per-value mask means every row of that value is valid; only materialized when the + // output can be null. + let validity = nullable.then(|| { + BitBufferMut::collect_bool(len, |i| { + value_validity[branches[i].as_()] + .as_ref() + .is_none_or(|mask| mask.value(rows[i].as_())) + }) + }); + + Ok((values, validity)) +} diff --git a/vortex-array/src/arrays/interleave/execute/mod.rs b/vortex-array/src/arrays/interleave/execute/mod.rs new file mode 100644 index 00000000000..05dcd161f62 --- /dev/null +++ b/vortex-array/src/arrays/interleave/execute/mod.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Execution logic for [`Interleave`], dispatched on the value type. +//! +//! All values share a type (validated in [`Interleave::check`]), so the +//! physical gather kernel is chosen from the first value. The selector types are an orthogonal +//! concern handled within each kernel. Only boolean values are implemented today (see the [`bool`] module). +//! +//! [`Interleave::check`]: super::Interleave::check +//! [`bool`]: module@crate::arrays::interleave::execute::bool + +mod bool; + +use vortex_error::VortexResult; +use vortex_error::vortex_panic; + +use super::Interleave; +use super::InterleaveArrayExt; +use crate::array::Array; +use crate::executor::ExecutionCtx; +use crate::executor::ExecutionResult; + +/// Executes an [`InterleaveArray`](super::InterleaveArray) by dispatching on the value type. +pub(super) fn execute( + array: Array, + ctx: &mut ExecutionCtx, +) -> VortexResult { + if array.value(0).dtype().is_boolean() { + bool::execute(array, ctx) + } else { + let value_dtype = array.value(0).dtype().clone(); + vortex_panic!( + "interleave execution is only implemented for boolean values; value dtype {} is not \ + yet supported", + value_dtype + ) + } +} diff --git a/vortex-array/src/arrays/interleave/mod.rs b/vortex-array/src/arrays/interleave/mod.rs new file mode 100644 index 00000000000..dd5619559dc --- /dev/null +++ b/vortex-array/src/arrays/interleave/mod.rs @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The [`Interleave`] encoding: a lazy, random-access gather of `N` value arrays into one array, +//! routed by a per-row `(array_index, row_index)` pair. +//! +//! # Specification +//! +//! An [`Interleave`] array has `N + 2` children: `N` *values* followed by an `array_indices` +//! selector and a `row_indices` selector. The output has `array_indices.len()` rows, and output +//! row `i` comes from `values[array_indices[i]][row_indices[i]]`. +//! +//! Unlike a `Merge`, which consumes each branch in order under a cursor, an [`Interleave`] is +//! **random-access**: `row_indices` names an explicit position within the selected value array, so +//! rows may be reordered, skipped, or repeated. A `Merge` is the special case where each value +//! array is consumed front-to-back exactly once. +//! +//! Like a `Merge`, the value arrays are independent: each holds only its own rows, and the +//! selectors stitch them back together. This distinguishes [`Interleave`] from an element-wise +//! select such as `zip`, whose arguments are all full-length. +//! +//! ## Invariants +//! +//! - Both selectors are **non-nullable** and equal in length, which is the output length. They +//! record *where* each output row comes from, which is always a definite decision. Predicate +//! nullability must be resolved into definite indices by the caller *before* the interleave is +//! built. +//! - `array_indices[i] < values.len()` and `row_indices[i] < values[array_indices[i]].len()` for +//! every `i`. These per-row bounds depend on the selector *values* and so are a runtime +//! precondition of the caller, checked in the execution kernels rather than at construction. +//! - All values share a logical type up to nullability. The output type is that shared type with +//! the union of the values' nullabilities. This is orthogonal to the selectors: a row's *value* +//! may be null even though its `(array_index, row_index)` is definite. +//! - The output length equals `array_indices.len()` (`== row_indices.len()`). +//! +//! ## Selector types +//! +//! `array_indices` encodes the value array per row as a non-nullable **unsigned integer** +//! (`array_indices[i]` is the index into `values`). `row_indices` is likewise a non-nullable +//! **unsigned integer** naming the position within the selected value array. + +mod execute; + +use std::fmt::Display; +use std::fmt::Formatter; +use std::hash::Hasher; + +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; +use vortex_error::vortex_panic; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use crate::ArrayEq; +use crate::ArrayHash; +use crate::ArrayRef; +use crate::EqMode; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::array::Array; +use crate::array::ArrayId; +use crate::array::ArrayParts; +use crate::array::ArraySlots; +use crate::array::ArrayView; +use crate::array::OperationsVTable; +use crate::array::TypedArrayRef; +use crate::array::VTable; +use crate::array::ValidityVTable; +use crate::arrays::ConstantArray; +use crate::buffer::BufferHandle; +use crate::dtype::DType; +use crate::dtype::Nullability; +use crate::executor::ExecutionResult; +use crate::scalar::Scalar; +use crate::serde::ArrayChildren; +use crate::validity::Validity; + +/// An [`Interleave`]-encoded Vortex array. See the [module docs](self) for the specification. +pub type InterleaveArray = Array; + +/// The [`Interleave`] encoding. See the [module docs](self). +#[derive(Clone, Debug)] +pub struct Interleave; + +/// Per-array metadata for an [`InterleaveArray`]. +/// +/// The values and selectors live in the array's slots; only the value count is stored here so the +/// selector slots can be located (`slots[num_values]` and `slots[num_values + 1]`). +#[derive(Clone, Debug)] +pub struct InterleaveData { + pub(crate) num_values: usize, +} + +impl Display for InterleaveData { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "num_values: {}", self.num_values) + } +} + +impl ArrayHash for InterleaveData { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { + state.write_usize(self.num_values); + } +} + +impl ArrayEq for InterleaveData { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { + self.num_values == other.num_values + } +} + +/// Accessors for the values and selectors of an [`InterleaveArray`]. +pub trait InterleaveArrayExt: TypedArrayRef { + /// The number of value arrays (two fewer than the number of children). + fn num_values(&self) -> usize { + self.num_values + } + + /// The `idx`-th value array (holding the rows that `array_indices` routes to it). + fn value(&self, idx: usize) -> &ArrayRef { + self.as_ref().slots()[idx] + .as_ref() + .vortex_expect("validated interleave value slot") + } + + /// The selector routing each output row to a value array. + fn array_indices(&self) -> &ArrayRef { + self.as_ref().slots()[self.num_values] + .as_ref() + .vortex_expect("validated interleave array_indices slot") + } + + /// The selector naming each output row's position within its value array. + fn row_indices(&self) -> &ArrayRef { + self.as_ref().slots()[self.num_values + 1] + .as_ref() + .vortex_expect("validated interleave row_indices slot") + } +} +impl> InterleaveArrayExt for T {} + +impl Interleave { + /// The single source of truth for [`InterleaveArray`] invariants. + /// + /// Validates `values`, `array_indices`, and `row_indices` against the [specification](self) and + /// returns the output [`DType`] (the shared value type with the union of value nullabilities). + /// Both the public constructor and the [`VTable::validate`] hook funnel through here. + fn check( + values: &[ArrayRef], + array_indices: &ArrayRef, + row_indices: &ArrayRef, + ) -> VortexResult { + vortex_ensure!( + values.len() >= 2, + "interleave requires at least 2 values, got {}", + values.len() + ); + + // Both selectors are non-nullable unsigned integers: `array_indices` indexes the values and + // `row_indices` names a position within the selected value. + for (name, selector) in [ + ("array_indices", array_indices), + ("row_indices", row_indices), + ] { + match selector.dtype() { + DType::Primitive(ptype, nullability) if ptype.is_unsigned_int() => { + vortex_ensure!( + !nullability.is_nullable(), + "interleave {name} must be non-nullable, got {}", + selector.dtype() + ); + } + other => vortex_bail!( + "interleave {name} must be a non-nullable unsigned integer, got {other}" + ), + } + } + + vortex_ensure!( + array_indices.len() == row_indices.len(), + "interleave selectors must have equal length, got array_indices {} and row_indices {}", + array_indices.len(), + row_indices.len() + ); + + let base_dtype = values[0].dtype(); + let mut nullability = Nullability::NonNullable; + for value in values { + vortex_ensure!( + value.dtype().eq_ignore_nullability(base_dtype), + "interleave values must share a dtype up to nullability: {} vs {}", + base_dtype, + value.dtype() + ); + nullability |= value.dtype().nullability(); + } + + Ok(base_dtype.with_nullability(nullability)) + } +} + +impl Array { + /// Constructs a new [`InterleaveArray`] from `values` and the `array_indices` / `row_indices` + /// selectors. + /// + /// See the [module docs](self) for the full specification and invariants. The selectors must be + /// non-nullable: they record a definite `(array_index, row_index)` per row, so null-predicate + /// handling is the caller's responsibility, resolved before the interleave is constructed. The + /// per-row bounds on the selector values are a runtime precondition checked during execution. + pub fn try_new( + values: Vec, + array_indices: ArrayRef, + row_indices: ArrayRef, + ) -> VortexResult { + let dtype = Interleave::check(&values, &array_indices, &row_indices)?; + let len = array_indices.len(); + let num_values = values.len(); + + let mut slots: ArraySlots = values.into_iter().map(Some).collect(); + slots.push(Some(array_indices)); + slots.push(Some(row_indices)); + + Ok(unsafe { + Array::from_parts_unchecked( + ArrayParts::new(Interleave, dtype, len, InterleaveData { num_values }) + .with_slots(slots), + ) + }) + } +} + +impl VTable for Interleave { + type TypedArrayData = InterleaveData; + type OperationsVTable = Self; + type ValidityVTable = Self; + + fn id(&self) -> ArrayId { + static ID: CachedId = CachedId::new("vortex.interleave"); + *ID + } + + fn validate( + &self, + data: &Self::TypedArrayData, + dtype: &DType, + len: usize, + slots: &[Option], + ) -> VortexResult<()> { + vortex_ensure!( + slots.len() == data.num_values + 2, + "InterleaveArray expected {} slots (values + array_indices + row_indices), got {}", + data.num_values + 2, + slots.len() + ); + vortex_ensure!( + slots.iter().all(|s| s.is_some()), + "InterleaveArray slots must all be present" + ); + + let values: Vec = slots[..data.num_values] + .iter() + .map(|s| s.clone().vortex_expect("validated value slot")) + .collect(); + let array_indices = slots[data.num_values] + .clone() + .vortex_expect("validated array_indices slot"); + let row_indices = slots[data.num_values + 1] + .clone() + .vortex_expect("validated row_indices slot"); + + // All semantic invariants live in `check`; here we only confirm the array's cached `dtype` + // and `len` agree with what the children imply. + let expected_dtype = Interleave::check(&values, &array_indices, &row_indices)?; + vortex_ensure!( + dtype == &expected_dtype, + "InterleaveArray dtype {} does not match the dtype implied by its children {}", + dtype, + expected_dtype + ); + vortex_ensure!( + len == array_indices.len(), + "InterleaveArray length {} does not match array_indices length {}", + len, + array_indices.len() + ); + Ok(()) + } + + fn nbuffers(_array: ArrayView<'_, Self>) -> usize { + 0 + } + + fn buffer(_array: ArrayView<'_, Self>, _idx: usize) -> BufferHandle { + vortex_panic!("InterleaveArray has no buffers") + } + + fn buffer_name(_array: ArrayView<'_, Self>, _idx: usize) -> Option { + None + } + + fn slot_name(array: ArrayView<'_, Self>, idx: usize) -> String { + if idx == array.num_values() { + "array_indices".to_string() + } else if idx == array.num_values() + 1 { + "row_indices".to_string() + } else { + format!("value_{idx}") + } + } + + fn serialize( + _array: ArrayView<'_, Self>, + _session: &VortexSession, + ) -> VortexResult>> { + vortex_bail!("Interleave array is not serializable") + } + + fn deserialize( + &self, + _dtype: &DType, + _len: usize, + _metadata: &[u8], + _buffers: &[BufferHandle], + _children: &dyn ArrayChildren, + _session: &VortexSession, + ) -> VortexResult> { + vortex_bail!("Interleave array is not serializable") + } + + fn execute(array: Array, ctx: &mut ExecutionCtx) -> VortexResult { + execute::execute(array, ctx) + } +} + +impl OperationsVTable for Interleave { + fn scalar_at( + array: ArrayView<'_, Interleave>, + index: usize, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + // Random-access gather: read the routing pair for `index` directly, then pull that row from + // the selected value array. No cursor walk is required. + let branch_idx = array + .array_indices() + .execute_scalar(index, ctx)? + .as_primitive() + .as_::() + .vortex_expect("interleave array_indices is non-nullable"); + let row = array + .row_indices() + .execute_scalar(index, ctx)? + .as_primitive() + .as_::() + .vortex_expect("interleave row_indices is non-nullable"); + + let scalar = array.value(branch_idx).execute_scalar(row, ctx)?; + // The value may be non-nullable while the interleaved output is nullable; align the dtype. + Ok(if array.as_ref().dtype().is_nullable() { + scalar.into_nullable() + } else { + scalar + }) + } +} + +impl ValidityVTable for Interleave { + fn validity(array: ArrayView<'_, Interleave>) -> VortexResult { + if !array.as_ref().dtype().is_nullable() { + return Ok(Validity::NonNullable); + } + // The output validity is itself an interleave — by the same selectors — of the values' + // validities, expressed as non-nullable boolean arrays. This bottoms out immediately + // because the inner interleave is non-nullable. + let mut value_validities: Vec = Vec::with_capacity(array.num_values()); + for i in 0..array.num_values() { + value_validities.push(value_validity_array(array.value(i))?); + } + let interleaved = InterleaveArray::try_new( + value_validities, + array.array_indices().clone(), + array.row_indices().clone(), + )?; + Ok(Validity::Array(interleaved.into_array())) + } +} + +/// Materializes a value's validity as a non-nullable boolean array of the value's length, where +/// `true` marks a valid (non-null) row. +fn value_validity_array(value: &ArrayRef) -> VortexResult { + Ok(match value.validity()? { + Validity::NonNullable | Validity::AllValid => { + ConstantArray::new(true, value.len()).into_array() + } + Validity::AllInvalid => ConstantArray::new(false, value.len()).into_array(), + Validity::Array(array) => array, + }) +} + +#[cfg(test)] +mod tests { + use vortex_error::VortexResult; + + use super::*; + use crate::Canonical; + use crate::LEGACY_SESSION; + use crate::VortexSessionExecute; + use crate::arrays::BoolArray; + use crate::arrays::PrimitiveArray; + use crate::assert_arrays_eq; + + /// Reference (oracle) implementation of the interleave spec, used only to validate the optimized + /// [execute](super::execute) path. It is intentionally simple and slow: it pulls each output + /// element one [`Scalar`] at a time via [`ArrayRef::execute_scalar`] and never touches raw bits. + /// + /// This is deliberately *not* wired into the array execution path — it exists purely as a + /// trustworthy comparison point in tests. + fn interleave_reference( + values: &[ArrayRef], + array_indices: &ArrayRef, + row_indices: &ArrayRef, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let len = array_indices.len(); + let nullable = values.iter().any(|v| v.dtype().is_nullable()); + let mut out: Vec> = Vec::with_capacity(len); + + for i in 0..len { + let j = array_indices + .execute_scalar(i, ctx)? + .as_primitive() + .as_::() + .vortex_expect("array_indices is non-nullable"); + let row = row_indices + .execute_scalar(i, ctx)? + .as_primitive() + .as_::() + .vortex_expect("row_indices is non-nullable"); + out.push(values[j].execute_scalar(row, ctx)?.as_bool().value()); + } + + Ok(if nullable { + BoolArray::from_iter(out).into_array() + } else { + BoolArray::from_iter( + out.into_iter() + .map(|v| v.vortex_expect("non-nullable value produced a null")), + ) + .into_array() + }) + } + + /// Builds the compact value arrays and the unsigned `(array_indices, row_indices)` selectors for + /// a gather described by per-output `(array_index, row_index)` pairs over `branches`. + fn build( + branches: &[&[Option]], + indices: &[(usize, usize)], + ) -> (Vec, ArrayRef, ArrayRef) { + let nullable = branches.iter().flat_map(|b| b.iter()).any(Option::is_none); + let to_value = |vals: &[Option]| -> ArrayRef { + if nullable { + BoolArray::from_iter(vals.iter().copied()).into_array() + } else { + BoolArray::from_iter( + vals.iter() + .map(|v| v.vortex_expect("non-nullable value produced a null")), + ) + .into_array() + } + }; + + let values = branches.iter().map(|b| to_value(b)).collect(); + let array_indices = PrimitiveArray::from_iter( + indices + .iter() + .map(|&(a, _)| u32::try_from(a).vortex_expect("array index fits in u32")), + ) + .into_array(); + let row_indices = PrimitiveArray::from_iter( + indices + .iter() + .map(|&(_, r)| u32::try_from(r).vortex_expect("row index fits in u32")), + ) + .into_array(); + (values, array_indices, row_indices) + } + + /// Asserts that the optimized execute path and the reference implementation agree, exercising + /// `InterleaveArray` construction, `execute`, `scalar_at`, and `validity` (via + /// `assert_arrays_eq`). + fn check(branches: &[&[Option]], indices: &[(usize, usize)]) -> VortexResult<()> { + let (values, array_indices, row_indices) = build(branches, indices); + + let interleaved = + InterleaveArray::try_new(values.clone(), array_indices.clone(), row_indices.clone())? + .into_array(); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let reference = interleave_reference(&values, &array_indices, &row_indices, &mut ctx)?; + + assert_arrays_eq!(interleaved, reference); + Ok(()) + } + + #[test] + fn interleave_reorders_and_repeats() -> VortexResult<()> { + // Random access: rows are pulled out of order and branch 0 row 0 is repeated. + check( + &[&[Some(true), Some(false)], &[Some(false), Some(true)]], + &[(0, 1), (1, 0), (0, 0), (1, 1), (0, 0)], + ) + } + + #[test] + fn interleave_skips_rows() -> VortexResult<()> { + // Branch 0 row 1 and branch 1 row 0 are never gathered. + check( + &[ + &[Some(true), Some(false), Some(true)], + &[Some(false), Some(true)], + ], + &[(0, 0), (1, 1), (0, 2)], + ) + } + + #[test] + fn interleave_three_values() -> VortexResult<()> { + // An unsigned `array_indices` routes among three values with full random access. + check( + &[ + &[Some(true), Some(false)], + &[Some(false)], + &[Some(true), Some(true), Some(false)], + ], + &[(2, 1), (0, 0), (1, 0), (2, 2), (0, 1), (2, 0)], + ) + } + + #[test] + fn interleave_only_one_branch() -> VortexResult<()> { + check( + &[&[Some(true), Some(false), Some(true)], &[Some(false)]], + &[(0, 2), (0, 0), (0, 1)], + ) + } + + #[test] + fn interleave_nullable_with_nulls_in_values() -> VortexResult<()> { + check( + &[&[None, Some(true), None], &[Some(false), None]], + &[(1, 1), (0, 0), (1, 0), (0, 2), (0, 1)], + ) + } + + #[test] + fn interleave_empty() -> VortexResult<()> { + check(&[&[Some(true)], &[Some(false)]], &[]) + } + + #[test] + fn rejects_boolean_array_indices() { + let value = BoolArray::from_iter([true, false]).into_array(); + let array_indices = BoolArray::from_iter([true, false]).into_array(); + let row_indices = PrimitiveArray::from_iter([0u32, 1]).into_array(); + let err = InterleaveArray::try_new(vec![value.clone(), value], array_indices, row_indices) + .err() + .vortex_expect("expected interleave to reject a boolean array_indices"); + assert!(err.to_string().contains("unsigned integer"), "{err}"); + } + + #[test] + fn rejects_signed_integer_array_indices() { + let value = BoolArray::from_iter([true]).into_array(); + let array_indices = PrimitiveArray::from_iter([0i32, 1]).into_array(); + let row_indices = PrimitiveArray::from_iter([0u32, 0]).into_array(); + let err = InterleaveArray::try_new(vec![value.clone(), value], array_indices, row_indices) + .err() + .vortex_expect("expected interleave to reject a signed integer array_indices"); + assert!(err.to_string().contains("unsigned integer"), "{err}"); + } + + #[test] + fn rejects_nullable_row_indices() { + let value = BoolArray::from_iter([true, false]).into_array(); + let array_indices = PrimitiveArray::from_iter([0u32, 1]).into_array(); + let row_indices = PrimitiveArray::from_option_iter([Some(0u32), Some(1)]).into_array(); + let err = InterleaveArray::try_new(vec![value.clone(), value], array_indices, row_indices) + .err() + .vortex_expect("expected interleave to reject nullable row_indices"); + assert!(err.to_string().contains("non-nullable"), "{err}"); + } + + #[test] + fn rejects_mismatched_selector_lengths() { + let value = BoolArray::from_iter([true, false]).into_array(); + let array_indices = PrimitiveArray::from_iter([0u32, 1]).into_array(); + let row_indices = PrimitiveArray::from_iter([0u32]).into_array(); + let err = InterleaveArray::try_new(vec![value.clone(), value], array_indices, row_indices) + .err() + .vortex_expect("expected interleave to reject mismatched selector lengths"); + assert!(err.to_string().contains("equal length"), "{err}"); + } + + #[test] + #[should_panic(expected = "only implemented for boolean values")] + fn non_boolean_value_execution_panics() { + // Execution dispatches on the value type: primitive values have no kernel yet. + let v0 = PrimitiveArray::from_iter([1u32]).into_array(); + let v1 = PrimitiveArray::from_iter([2u32]).into_array(); + let array_indices = PrimitiveArray::from_iter([0u32, 1]).into_array(); + let row_indices = PrimitiveArray::from_iter([0u32, 0]).into_array(); + let interleaved = InterleaveArray::try_new(vec![v0, v1], array_indices, row_indices) + .vortex_expect("primitive values should construct") + .into_array(); + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + interleaved.execute::(&mut ctx).ok(); + } +} diff --git a/vortex-array/src/arrays/list/array.rs b/vortex-array/src/arrays/list/array.rs index 3e1c43ea3e9..7788511aa25 100644 --- a/vortex-array/src/arrays/list/array.rs +++ b/vortex-array/src/arrays/list/array.rs @@ -15,6 +15,8 @@ use vortex_error::vortex_panic; use crate::ArrayRef; use crate::ArraySlots; +use crate::Canonical; +use crate::ExecutionCtx; use crate::IntoArray; use crate::LEGACY_SESSION; use crate::VortexSessionExecute; @@ -329,21 +331,24 @@ pub trait ListArrayExt: TypedArrayRef { self.elements().dtype() } - fn reset_offsets(&self, recurse: bool) -> VortexResult> { + fn reset_offsets(&self, recurse: bool, ctx: &mut ExecutionCtx) -> VortexResult> { let mut elements = self.sliced_elements()?; if recurse && elements.is_canonical() { - #[expect(deprecated)] - let compacted = elements.to_canonical()?.compact()?.into_array(); + let compacted = elements + .clone() + .execute::(ctx)? + .compact(ctx)? + .into_array(); elements = compacted; } else if recurse && let Some(child_list_array) = elements.as_opt::() { elements = child_list_array .into_owned() - .reset_offsets(recurse)? + .reset_offsets(recurse, ctx)? .into_array(); } let offsets = self.offsets(); - let first_offset = offsets.execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx())?; + let first_offset = offsets.execute_scalar(0, ctx)?; let adjusted_offsets = offsets.clone().binary( ConstantArray::new(first_offset, offsets.len()).into_array(), Operator::Sub, diff --git a/vortex-array/src/arrays/list/compute/take.rs b/vortex-array/src/arrays/list/compute/take.rs index 43117b1b834..f96cac5cf32 100644 --- a/vortex-array/src/arrays/list/compute/take.rs +++ b/vortex-array/src/arrays/list/compute/take.rs @@ -164,8 +164,8 @@ fn _take_nullable = + LazyLock::new(|| VortexSession::empty().with::()); + #[test] fn test_empty_list_array() { let elements = PrimitiveArray::empty::(Nullability::NonNullable); @@ -39,6 +45,7 @@ fn test_empty_list_array() { #[test] fn test_simple_list_array() { + let mut ctx = SESSION.create_execution_ctx(); let elements = buffer![1i32, 2, 3, 4, 5].into_array(); let offsets = buffer![0, 2, 4, 5].into_array(); let validity = Validity::AllValid; @@ -51,8 +58,7 @@ fn test_simple_list_array() { vec![1.into(), 2.into()], Nullability::Nullable ), - list.execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + list.execute_scalar(0, &mut ctx).unwrap() ); assert_eq!( Scalar::list( @@ -60,18 +66,17 @@ fn test_simple_list_array() { vec![3.into(), 4.into()], Nullability::Nullable ), - list.execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + list.execute_scalar(1, &mut ctx).unwrap() ); assert_eq!( Scalar::list(Arc::new(I32.into()), vec![5.into()], Nullability::Nullable), - list.execute_scalar(2, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + list.execute_scalar(2, &mut ctx).unwrap() ); } #[test] fn test_simple_list_array_from_iter() { + let mut ctx = SESSION.create_execution_ctx(); let elements = buffer![1i32, 2, 3].into_array(); let offsets = buffer![0, 2, 3].into_array(); let validity = Validity::NonNullable; @@ -84,18 +89,12 @@ fn test_simple_list_array_from_iter() { assert_eq!(list.len(), list_from_iter.len()); assert_eq!( - list.execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), - list_from_iter - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + list.execute_scalar(0, &mut ctx).unwrap(), + list_from_iter.execute_scalar(0, &mut ctx).unwrap() ); assert_eq!( - list.execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), - list_from_iter - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + list.execute_scalar(1, &mut ctx).unwrap(), + list_from_iter.execute_scalar(1, &mut ctx).unwrap() ); } @@ -208,6 +207,7 @@ fn test_list_filter_empty_lists() { #[test] fn test_list_filter_with_nulls() { + let mut ctx = SESSION.create_execution_ctx(); // Test filtering lists with null validity. let elements = buffer![0..15].into_array(); let offsets = buffer![0, 3, 7, 10, 12, 15].into_array(); @@ -227,30 +227,10 @@ fn test_list_filter_with_nulls() { assert_eq!(filtered.len(), 4); // Check validity of filtered array using scalar_at (works on any array). - assert!( - filtered - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - .is_valid() - ); - assert!( - !filtered - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - .is_valid() - ); // Was null. - assert!( - !filtered - .execute_scalar(2, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - .is_valid() - ); // Was null. - assert!( - filtered - .execute_scalar(3, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() - .is_valid() - ); + assert!(filtered.execute_scalar(0, &mut ctx).unwrap().is_valid()); + assert!(!filtered.execute_scalar(1, &mut ctx).unwrap().is_valid()); // Was null. + assert!(!filtered.execute_scalar(2, &mut ctx).unwrap().is_valid()); // Was null. + assert!(filtered.execute_scalar(3, &mut ctx).unwrap().is_valid()); } #[test] @@ -655,7 +635,7 @@ fn test_list_of_lists() { // Test scalar conversion. let scalar = list_of_lists - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(0, &mut SESSION.create_execution_ctx()) .unwrap(); assert!(matches!(scalar.dtype(), DType::List(_, _))); let list_scalar = scalar.as_list(); @@ -679,6 +659,7 @@ fn test_list_of_lists() { #[test] fn test_list_of_lists_nullable_outer() { + let mut ctx = SESSION.create_execution_ctx(); // Create list of lists with nullable outer, non-nullable inner. // Structure: [[[1, 2], [3]], null, [[4, 5, 6]], [[7]]] let data = vec![ @@ -701,15 +682,11 @@ fn test_list_of_lists_nullable_outer() { )); // First element should be [[1, 2], [3]]. - let first = list_of_lists - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let first = list_of_lists.execute_scalar(0, &mut ctx).unwrap(); assert!(!first.is_null()); // Second element should be null. - let second = list_of_lists - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let second = list_of_lists.execute_scalar(1, &mut ctx).unwrap(); assert!(second.is_null()); // Third element should be [[4, 5, 6]]. @@ -764,13 +741,14 @@ fn test_list_of_lists_nullable_inner() { // Check that second inner list is null. let second_inner = first_list .array() - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(1, &mut SESSION.create_execution_ctx()) .unwrap(); assert!(second_inner.is_null()); } #[test] fn test_list_of_lists_both_nullable() { + let mut ctx = SESSION.create_execution_ctx(); // Create list of lists with both nullable. // Structure: [[[1, 2], null], null, [[3]], [null]] let data = vec![ @@ -793,9 +771,7 @@ fn test_list_of_lists_both_nullable() { )); // First outer list should have 2 elements, second is null inner list. - let first_outer = list_of_lists - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let first_outer = list_of_lists.execute_scalar(0, &mut ctx).unwrap(); assert!(!first_outer.is_null()); let first_outer_array = list_of_lists.list_elements_at(0).unwrap(); let first_list = first_outer_array.as_::(); @@ -806,16 +782,11 @@ fn test_list_of_lists_both_nullable() { assert_eq!(first_inner.len(), 2); // Second inner list should be null. - let second_inner = first_list - .array() - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let second_inner = first_list.array().execute_scalar(1, &mut ctx).unwrap(); assert!(second_inner.is_null()); // Second outer list should be null. - let second_outer = list_of_lists - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let second_outer = list_of_lists.execute_scalar(1, &mut ctx).unwrap(); assert!(second_outer.is_null()); // Third outer list should have [3]. @@ -829,10 +800,7 @@ fn test_list_of_lists_both_nullable() { let fourth_outer = list_of_lists.list_elements_at(3).unwrap(); let fourth_list = fourth_outer.as_::(); assert_eq!(fourth_list.len(), 1); - let inner = fourth_list - .array() - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let inner = fourth_list.array().execute_scalar(0, &mut ctx).unwrap(); assert!(inner.is_null()); } @@ -925,6 +893,7 @@ fn test_offsets_constant() { #[test] fn test_recursive_compact_list_of_lists() { + let mut ctx = SESSION.create_execution_ctx(); // Create a nested list structure: [[[1,2,3], [4,5]], [[6,7,8,9]], [[10], [11,12]]] let nested_data = vec![ Some(vec![ @@ -941,9 +910,9 @@ fn test_recursive_compact_list_of_lists() { let sliced_list = sliced.as_::(); // Test non-recursive compaction: only resets outer list offsets - let non_recursive = sliced_list.reset_offsets(false).unwrap(); + let non_recursive = sliced_list.reset_offsets(false, &mut ctx).unwrap(); // Test recursive compaction: resets offsets and compacts inner canonical arrays - let recursive = sliced_list.reset_offsets(true).unwrap(); + let recursive = sliced_list.reset_offsets(true, &mut ctx).unwrap(); assert_eq!(non_recursive.len(), 2); assert_eq!(recursive.len(), 2); @@ -964,12 +933,8 @@ fn test_recursive_compact_list_of_lists() { let non_recursive_array = non_recursive.into_array(); let recursive_array = recursive.into_array(); assert_eq!( - non_recursive_array - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(), - recursive_array - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap() + non_recursive_array.execute_scalar(0, &mut ctx).unwrap(), + recursive_array.execute_scalar(0, &mut ctx).unwrap() ); } @@ -985,7 +950,7 @@ fn test_filter_sliced_list_array() -> VortexResult<()> { let mask = Mask::from(BitBuffer::from(vec![true, false, true])); let filter_array = FilterArray::new(list, mask).into_array(); - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let result = filter_array.execute::(&mut ctx)?; assert_eq!(result.len(), 2); diff --git a/vortex-array/src/arrays/list/vtable/mod.rs b/vortex-array/src/arrays/list/vtable/mod.rs index 1b404e9edbc..d93c4673302 100644 --- a/vortex-array/src/arrays/list/vtable/mod.rs +++ b/vortex-array/src/arrays/list/vtable/mod.rs @@ -16,10 +16,10 @@ use vortex_session::registry::CachedId; use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; use crate::IntoArray; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayParts; @@ -54,11 +54,11 @@ pub struct ListMetadata { } impl ArrayHash for ListData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl ArrayEq for ListData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } diff --git a/vortex-array/src/arrays/listview/compute/mod.rs b/vortex-array/src/arrays/listview/compute/mod.rs index 9a43503c4b5..87587495f7a 100644 --- a/vortex-array/src/arrays/listview/compute/mod.rs +++ b/vortex-array/src/arrays/listview/compute/mod.rs @@ -6,3 +6,4 @@ mod mask; pub(crate) mod rules; mod slice; mod take; +mod zip; diff --git a/vortex-array/src/arrays/listview/compute/zip.rs b/vortex-array/src/arrays/listview/compute/zip.rs new file mode 100644 index 00000000000..1423a14804c --- /dev/null +++ b/vortex-array/src/arrays/listview/compute/zip.rs @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::ops::BitAnd; +use std::ops::BitOr; +use std::ops::Not; + +use vortex_buffer::Buffer; +use vortex_buffer::BufferMut; +use vortex_error::VortexResult; +use vortex_mask::Mask; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::array::ArrayView; +use crate::arrays::Chunked; +use crate::arrays::ChunkedArray; +use crate::arrays::ListView; +use crate::arrays::ListViewArray; +use crate::arrays::chunked::ChunkedArrayExt; +use crate::arrays::listview::ListViewArrayExt; +use crate::builtins::ArrayBuiltins; +use crate::dtype::DType; +use crate::dtype::Nullability; +use crate::dtype::PType; +use crate::scalar_fn::fns::zip::ZipKernel; +use crate::validity::Validity; + +/// Zip two [`ListViewArray`]s by selecting whole list views per row. +/// +/// A [`ListViewArray`] addresses each list by an `(offset, size)` pair into a shared `elements` +/// array, and unlike [`ListArray`](crate::arrays::ListArray) it does not require lists to be stored +/// contiguously or in order. Zipping two list views is therefore a metadata-only operation over the +/// `offsets`, `sizes` and `validity` child arrays: we concatenate the two `elements` arrays +/// (without rewriting them) and, for each row, select the `(offset, size)` pair from `if_true` or +/// `if_false` per the mask. `if_false` views are shifted past the end of `if_true`'s elements so +/// they continue to address the correct half of the concatenated elements array. +impl ZipKernel for ListView { + fn zip( + if_true: ArrayView<'_, ListView>, + if_false: &ArrayRef, + mask: &ArrayRef, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { + let Some(if_false) = if_false.as_opt::() else { + return Ok(None); + }; + + // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics. + let mask = mask.try_to_mask_fill_null_false(ctx)?; + match &mask { + // Defer the trivial masks to the generic zip, which just casts one side. + Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None), + Mask::Values(_) => {} + } + + let len = if_true.len(); + + let result_elements_dtype = if_true + .elements() + .dtype() + .union_nullability(if_false.elements().dtype().nullability()); + + // `if_false`'s elements share the element dtype up to nullability; normalize so both chunks + // of the concatenated elements array have an identical dtype. + let true_elements = if_true.elements().cast(result_elements_dtype.clone())?; + let false_elements = if_false.elements().cast(result_elements_dtype.clone())?; + + // `if_false` views index into the second half of the concatenated elements. + let false_shift = true_elements.len() as u64; + + // Concatenate the two `elements` arrays without copying. If either side is already a + // `ChunkedArray` (e.g. the result of a previous list-view zip), splice its chunks in + // directly rather than nesting chunked arrays. + let mut chunks = Vec::with_capacity(2); + push_element_chunks(true_elements, &mut chunks); + push_element_chunks(false_elements, &mut chunks); + let elements = ChunkedArray::try_new(chunks, result_elements_dtype)?.into_array(); + + let true_offsets = to_u64(if_true.offsets(), ctx)?; + let true_sizes = to_u64(if_true.sizes(), ctx)?; + let false_offsets = to_u64(if_false.offsets(), ctx)?; + let false_sizes = to_u64(if_false.sizes(), ctx)?; + + let mut offsets = BufferMut::::with_capacity(len); + let mut sizes = BufferMut::::with_capacity(len); + for ((idx, (out_offsets, out_sizes)), selected) in offsets + .spare_capacity_mut() + .iter_mut() + .zip(sizes.spare_capacity_mut().iter_mut()) + .take(len) + .enumerate() + .zip(mask.iter()) + { + if selected { + out_offsets.write(true_offsets[idx]); + out_sizes.write(true_sizes[idx]); + } else { + out_offsets.write(false_offsets[idx] + false_shift); + out_sizes.write(false_sizes[idx]); + } + } + + // SAFETY: the loop above initialized exactly `len` slots in both buffers. + unsafe { + offsets.set_len(len); + sizes.set_len(len); + } + + let validity = zip_validity(if_true.validity()?, if_false.validity()?, &mask, ctx)?; + + Ok(Some( + ListViewArray::try_new( + elements, + offsets.freeze().into_array(), + sizes.freeze().into_array(), + validity, + )? + .into_array(), + )) + } +} + +/// Appends `array`'s element chunks to `chunks`, flattening a top-level [`ChunkedArray`] so the +/// concatenated elements never nest chunked arrays. +fn push_element_chunks(array: ArrayRef, chunks: &mut Vec) { + match array.as_opt::() { + Some(chunked) => chunks.extend(chunked.iter_chunks().cloned()), + None => chunks.push(array), + } +} + +/// Read a non-nullable integer array into a `u64` buffer. +fn to_u64(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult> { + array + .clone() + .cast(DType::Primitive(PType::U64, Nullability::NonNullable))? + .execute::>(ctx) +} + +/// Combine the two list-level validities, taking `if_true`'s validity where `mask` is set and +/// `if_false`'s where it is not. +fn zip_validity( + if_true: Validity, + if_false: Validity, + mask: &Mask, + ctx: &mut ExecutionCtx, +) -> VortexResult { + Ok(match (&if_true, &if_false) { + (Validity::NonNullable, Validity::NonNullable) => Validity::NonNullable, + (Validity::AllValid, Validity::AllValid) => Validity::AllValid, + (Validity::AllInvalid, Validity::AllInvalid) => Validity::AllInvalid, + _ => { + let true_mask = if_true.execute_mask(mask.len(), ctx)?; + let false_mask = if_false.execute_mask(mask.len(), ctx)?; + let combined = true_mask + .bitand(mask) + .bitor(&false_mask.bitand(&mask.not())); + Validity::from_mask(combined, if_true.nullability() | if_false.nullability()) + } + }) +} + +#[cfg(test)] +mod tests { + use vortex_buffer::buffer; + use vortex_error::VortexResult; + use vortex_mask::Mask; + + use crate::ArrayRef; + use crate::IntoArray; + use crate::LEGACY_SESSION; + use crate::VortexSessionExecute; + use crate::arrays::BoolArray; + use crate::arrays::Chunked; + use crate::arrays::ChunkedArray; + use crate::arrays::ListView; + use crate::arrays::ListViewArray; + use crate::arrays::chunked::ChunkedArrayExt; + use crate::arrays::listview::ListViewArrayExt; + use crate::assert_arrays_eq; + use crate::builtins::ArrayBuiltins; + use crate::dtype::DType; + use crate::dtype::Nullability; + use crate::dtype::PType; + use crate::validity::Validity; + + fn list_view( + elements: ArrayRef, + offsets: ArrayRef, + sizes: ArrayRef, + validity: Validity, + ) -> ArrayRef { + ListViewArray::try_new(elements, offsets, sizes, validity) + .unwrap() + .into_array() + } + + /// `zip` of two list views selects whole lists per the mask and keeps the list encoding. + #[test] + fn zip_selects_lists() -> VortexResult<()> { + // [[1, 2], [3], [4, 5, 6]] + let if_true = list_view( + buffer![1i32, 2, 3, 4, 5, 6].into_array(), + buffer![0u32, 2, 3].into_array(), + buffer![2u32, 1, 3].into_array(), + Validity::NonNullable, + ); + // [[10], [20, 21], [30]] + let if_false = list_view( + buffer![10i32, 20, 21, 30].into_array(), + buffer![0u32, 1, 3].into_array(), + buffer![1u32, 2, 1].into_array(), + Validity::NonNullable, + ); + let mask = Mask::from_iter([true, false, true]); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + + // The kernel should keep the list-view encoding rather than canonicalizing. + assert!(result.is::()); + + // Expected: [[1, 2], [20, 21], [4, 5, 6]] + let expected = list_view( + buffer![1i32, 2, 20, 21, 4, 5, 6].into_array(), + buffer![0u32, 2, 4].into_array(), + buffer![2u32, 2, 3].into_array(), + Validity::NonNullable, + ); + assert_arrays_eq!(result, expected); + Ok(()) + } + + /// `zip` selects list-level validity from the chosen side and widens nullability. + #[test] + fn zip_selects_validity() -> VortexResult<()> { + // [[1], null, [2]] (list-level nulls) + let if_true = list_view( + buffer![1i32, 2].into_array(), + buffer![0u32, 1, 1].into_array(), + buffer![1u32, 0, 1].into_array(), + Validity::Array(BoolArray::from_iter([true, false, true]).into_array()), + ); + // [[10], [20], null] + let if_false = list_view( + buffer![10i32, 20].into_array(), + buffer![0u32, 1, 2].into_array(), + buffer![1u32, 1, 0].into_array(), + Validity::Array(BoolArray::from_iter([true, true, false]).into_array()), + ); + // true -> if_true, false -> if_false + let mask = Mask::from_iter([false, true, true]); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + + // Row 0 -> if_false[0] = [10]; row 1 -> if_true[1] = null; row 2 -> if_true[2] = [2] + let expected = list_view( + buffer![10i32, 2].into_array(), + buffer![0u32, 1, 1].into_array(), + buffer![1u32, 0, 1].into_array(), + Validity::Array(BoolArray::from_iter([true, false, true]).into_array()), + ); + assert_arrays_eq!(result, expected); + Ok(()) + } + + /// `zip` handles out-of-order/non-contiguous offsets and widens nullability when only one side + /// is nullable. + #[test] + fn zip_out_of_order_offsets_and_widening() -> VortexResult<()> { + // [[5, 6], [7], [8, 9]] expressed with out-of-order offsets. + let if_true = list_view( + buffer![7i32, 8, 9, 5, 6].into_array(), + buffer![3u32, 0, 1].into_array(), + buffer![2u32, 1, 2].into_array(), + Validity::NonNullable, + ); + // [[100], null, [200, 201]] + let if_false = list_view( + buffer![100i32, 200, 201].into_array(), + buffer![0u32, 1, 1].into_array(), + buffer![1u32, 0, 2].into_array(), + Validity::Array(BoolArray::from_iter([true, false, true]).into_array()), + ); + let mask = Mask::from_iter([true, true, false]); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + assert!(result.is::()); + + // [[5, 6], [7], [200, 201]], all valid but nullable (widened by if_false). + let expected = list_view( + buffer![5i32, 6, 7, 200, 201].into_array(), + buffer![0u32, 2, 3].into_array(), + buffer![2u32, 1, 2].into_array(), + Validity::AllValid, + ); + assert_arrays_eq!(result, expected); + Ok(()) + } + + /// When an input's `elements` is already a [`ChunkedArray`], its chunks are spliced in rather + /// than nesting a chunked array inside the concatenated elements. + #[test] + fn zip_flattens_chunked_elements() -> VortexResult<()> { + // elements [1, 2, 3] stored as two chunks; lists [[1, 2], [3]]. + let chunked_elements = ChunkedArray::try_new( + vec![buffer![1i32, 2].into_array(), buffer![3i32].into_array()], + DType::Primitive(PType::I32, Nullability::NonNullable), + )? + .into_array(); + let if_true = list_view( + chunked_elements, + buffer![0u32, 2].into_array(), + buffer![2u32, 1].into_array(), + Validity::NonNullable, + ); + // [[10], [20]] + let if_false = list_view( + buffer![10i32, 20].into_array(), + buffer![0u32, 1].into_array(), + buffer![1u32, 1].into_array(), + Validity::NonNullable, + ); + let mask = Mask::from_iter([true, false]); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + + // The concatenated elements are chunked, but no chunk is itself a `ChunkedArray`. + let result_lv = result + .as_opt::() + .expect("zip keeps the list-view encoding"); + let chunked = result_lv + .elements() + .as_opt::() + .expect("zip concatenates elements into a chunked array"); + assert!( + chunked.iter_chunks().all(|chunk| !chunk.is::()), + "chunked elements must be flattened, not nested", + ); + + // [[1, 2], [20]] + let expected = list_view( + buffer![1i32, 2, 20].into_array(), + buffer![0u32, 2].into_array(), + buffer![2u32, 1].into_array(), + Validity::NonNullable, + ); + assert_arrays_eq!(result, expected); + Ok(()) + } +} diff --git a/vortex-array/src/arrays/listview/conversion.rs b/vortex-array/src/arrays/listview/conversion.rs index 54ee8c2705c..7b553041c68 100644 --- a/vortex-array/src/arrays/listview/conversion.rs +++ b/vortex-array/src/arrays/listview/conversion.rs @@ -8,8 +8,6 @@ use crate::ArrayRef; use crate::Canonical; use crate::ExecutionCtx; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::arrays::ExtensionArray; use crate::arrays::FixedSizeListArray; use crate::arrays::ListArray; @@ -29,8 +27,8 @@ use crate::match_each_integer_ptype; /// Creates a `ListViewArray` from a `ListArray` by computing `sizes` from `offsets`. /// -/// The output `ListViewArray` will be zero-copyable back to a `ListArray`, and additionally it -/// will not have any leading or trailing garbage data. +/// The output `ListViewArray` will be zero-copyable back to a `ListArray`, and additionally it will +/// not have any leading or trailing garbage data. pub fn list_view_from_list(list: ListArray, ctx: &mut ExecutionCtx) -> VortexResult { // If the list is empty, create an empty `ListViewArray` with the same offset `DType` as the // input. @@ -41,8 +39,7 @@ pub fn list_view_from_list(list: ListArray, ctx: &mut ExecutionCtx) -> VortexRes // We reset the offsets here because mostly for convenience, and also because callers of this // function might not expect the output `ListViewArray` to have a bunch of leading and trailing // garbage data when they turn it back into a `ListArray`. - let list = list.reset_offsets(false).vortex_expect("This can't fail"); - + let list = list.reset_offsets(false, ctx)?; let list_offsets = list.offsets().clone(); // Create `sizes` array by computing differences between consecutive offsets. @@ -108,15 +105,18 @@ fn build_sizes_from_offsets( /// /// Otherwise, this function fall back to the (very) expensive path and will rebuild the /// `ListArray` from scratch. -pub fn list_from_list_view(list_view: ListViewArray) -> VortexResult { +pub fn list_from_list_view( + list_view: ListViewArray, + ctx: &mut ExecutionCtx, +) -> VortexResult { // Rebuild as zero-copyable to list array and also trim all leading and trailing elements. - let zctl_array = list_view.rebuild(ListViewRebuildMode::MakeExact)?; + let zctl_array = list_view.rebuild(ListViewRebuildMode::MakeExact, ctx)?; debug_assert!(zctl_array.is_zero_copy_to_list()); let list_offsets = match_each_integer_ptype!(zctl_array.offsets().dtype().as_ptype(), |O| { // SAFETY: We just made the array zero-copyable to `ListArray`, so the safety contract is // upheld. - unsafe { build_list_offsets_from_list_view::(&zctl_array) } + unsafe { build_list_offsets_from_list_view::(&zctl_array, ctx) } }); // SAFETY: Because the shape of the `ListViewArray` is zero-copyable to a `ListArray`, we @@ -142,6 +142,7 @@ pub fn list_from_list_view(list_view: ListViewArray) -> VortexResult /// between `offset[i]` and `offset[i + 1]`. unsafe fn build_list_offsets_from_list_view( list_view: &ListViewArray, + ctx: &mut ExecutionCtx, ) -> ArrayRef { let len = list_view.len(); let mut offsets_builder = @@ -150,8 +151,11 @@ unsafe fn build_list_offsets_from_list_view( // Create uninit range for direct memory access. let mut offsets_range = offsets_builder.uninit_range(len + 1); - #[expect(deprecated)] - let offsets = list_view.offsets().to_primitive(); + let offsets = list_view + .offsets() + .clone() + .execute::(ctx) + .vortex_expect("list view offsets must be primitive after rebuild"); let offsets_slice = offsets.as_slice::(); debug_assert!(offsets_slice.is_sorted()); @@ -184,17 +188,20 @@ unsafe fn build_list_offsets_from_list_view( /// Recursively converts all `ListViewArray`s to `ListArray`s in a nested array structure. /// /// The conversion happens bottom-up, processing children before parents. -pub fn recursive_list_from_list_view(array: ArrayRef) -> VortexResult { +pub fn recursive_list_from_list_view( + array: ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { if !array.dtype().is_nested() { return Ok(array); } - #[expect(deprecated)] - let canonical = array.to_canonical()?; + let canonical = array.execute::(ctx)?; Ok(match canonical { Canonical::List(listview) => { - let converted_elements = recursive_list_from_list_view(listview.elements().clone())?; + let converted_elements = + recursive_list_from_list_view(listview.elements().clone(), ctx)?; debug_assert_eq!(converted_elements.len(), listview.elements().len()); // Avoid cloning if elements didn't change. @@ -216,12 +223,12 @@ pub fn recursive_list_from_list_view(array: ArrayRef) -> VortexResult }; // Make the conversion to `ListArray`. - let list_array = list_from_list_view(listview_with_converted_elements)?; + let list_array = list_from_list_view(listview_with_converted_elements, ctx)?; list_array.into_array() } Canonical::FixedSizeList(fixed_size_list) => { let converted_elements = - recursive_list_from_list_view(fixed_size_list.elements().clone())?; + recursive_list_from_list_view(fixed_size_list.elements().clone(), ctx)?; // Avoid cloning if elements didn't change. if !ArrayRef::ptr_eq(&converted_elements, fixed_size_list.elements()) { @@ -245,7 +252,7 @@ pub fn recursive_list_from_list_view(array: ArrayRef) -> VortexResult let mut any_changed = false; for field in fields.iter() { - let converted_field = recursive_list_from_list_view(field.clone())?; + let converted_field = recursive_list_from_list_view(field.clone(), ctx)?; // Avoid cloning if elements didn't change. any_changed |= !ArrayRef::ptr_eq(&converted_field, field); converted_fields.push(converted_field); @@ -266,7 +273,7 @@ pub fn recursive_list_from_list_view(array: ArrayRef) -> VortexResult } Canonical::Extension(ext_array) => { let converted_storage = - recursive_list_from_list_view(ext_array.storage_array().clone())?; + recursive_list_from_list_view(ext_array.storage_array().clone(), ctx)?; // Avoid cloning if elements didn't change. if !ArrayRef::ptr_eq(&converted_storage, ext_array.storage_array()) { @@ -286,6 +293,7 @@ mod tests { use vortex_error::VortexExpect; use vortex_error::VortexResult; + use super::super::tests::common::SESSION; use super::super::tests::common::create_basic_listview; use super::super::tests::common::create_empty_lists_listview; use super::super::tests::common::create_nullable_listview; @@ -293,9 +301,8 @@ mod tests { use super::recursive_list_from_list_view; use crate::ArrayEq; use crate::ArrayRef; + use crate::EqMode; use crate::IntoArray; - use crate::LEGACY_SESSION; - use crate::Precision; use crate::VortexSessionExecute; use crate::arrays::BoolArray; use crate::arrays::FixedSizeListArray; @@ -319,7 +326,7 @@ mod tests { let offsets = buffer![0u32, 3, 5, 7, 10].into_array(); let list_array = ListArray::try_new(elements.clone(), offsets, Validity::NonNullable)?; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let list_view = list_view_from_list(list_array.clone(), &mut ctx)?; // Verify structure. @@ -342,7 +349,8 @@ mod tests { #[test] fn test_listview_to_list_zero_copy() -> VortexResult<()> { let list_view = create_basic_listview(); - let list_array = list_from_list_view(list_view.clone())?; + let list_array = + list_from_list_view(list_view.clone(), &mut SESSION.create_execution_ctx())?; // Should have same elements. assert_arrays_eq!(list_view.elements().clone(), list_array.elements().clone()); @@ -366,12 +374,12 @@ mod tests { // This conversion will create an empty ListViewArray. // Note: list_view_from_list handles the empty case specially. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let empty_list_view = list_view_from_list(empty_list.clone(), &mut ctx)?; assert_eq!(empty_list_view.len(), 0); // Convert back. - let converted_back = list_from_list_view(empty_list_view)?; + let converted_back = list_from_list_view(empty_list_view, &mut ctx)?; assert_eq!(converted_back.len(), 0); // For empty arrays, we can't use assert_arrays_eq directly since the offsets might differ. // Just check that it's empty. @@ -387,7 +395,7 @@ mod tests { let validity = Validity::Array(BoolArray::from_iter(vec![true, false, true]).into_array()); let nullable_list = ListArray::try_new(elements, offsets, validity.clone())?; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let nullable_list_view = list_view_from_list(nullable_list.clone(), &mut ctx)?; // Verify validity is preserved. @@ -395,12 +403,12 @@ mod tests { nullable_list_view .validity() .vortex_expect("listview validity should be derivable") - .array_eq(&validity, Precision::Ptr) + .array_eq(&validity, EqMode::Ptr) ); assert_eq!(nullable_list_view.len(), 3); // Round-trip conversion. - let converted_back = list_from_list_view(nullable_list_view)?; + let converted_back = list_from_list_view(nullable_list_view, &mut ctx)?; assert_arrays_eq!(nullable_list, converted_back); Ok(()) } @@ -409,7 +417,8 @@ mod tests { fn test_non_zero_copy_listview_to_list() -> VortexResult<()> { // Create ListViewArray with overlapping lists (not zero-copyable). let list_view = create_overlapping_listview(); - let list_array = list_from_list_view(list_view.clone())?; + let list_array = + list_from_list_view(list_view.clone(), &mut SESSION.create_execution_ctx())?; // The resulting ListArray should have monotonic offsets. for i in 0..list_array.len() { @@ -425,10 +434,11 @@ mod tests { #[test] fn test_empty_sublists() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let empty_lists_view = create_empty_lists_listview(); // Convert to ListArray. - let list_array = list_from_list_view(empty_lists_view.clone())?; + let list_array = list_from_list_view(empty_lists_view.clone(), &mut ctx)?; assert_eq!(list_array.len(), 4); // All sublists should be empty. @@ -437,7 +447,6 @@ mod tests { } // Round-trip. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); let converted_back = list_view_from_list(list_array, &mut ctx)?; assert_arrays_eq!(empty_lists_view, converted_back); Ok(()) @@ -451,7 +460,7 @@ mod tests { let list_i32 = ListArray::try_new(elements.clone(), i32_offsets.clone(), Validity::NonNullable)?; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let list_view_i32 = list_view_from_list(list_i32.clone(), &mut ctx)?; assert_eq!(list_view_i32.offsets().dtype(), i32_offsets.dtype()); assert_eq!(list_view_i32.sizes().dtype(), i32_offsets.dtype()); @@ -472,24 +481,24 @@ mod tests { #[test] fn test_round_trip_conversions() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Test 1: Basic round-trip. let original = create_basic_listview(); - let to_list = list_from_list_view(original.clone())?; + let to_list = list_from_list_view(original.clone(), &mut ctx)?; let back_to_view = list_view_from_list(to_list, &mut ctx)?; assert_arrays_eq!(original, back_to_view); // Test 2: Nullable round-trip. let nullable = create_nullable_listview(); - let nullable_to_list = list_from_list_view(nullable.clone())?; + let nullable_to_list = list_from_list_view(nullable.clone(), &mut ctx)?; let nullable_back = list_view_from_list(nullable_to_list, &mut ctx)?; assert_arrays_eq!(nullable, nullable_back); // Test 3: Non-zero-copyable round-trip. let overlapping = create_overlapping_listview(); - let overlapping_to_list = list_from_list_view(overlapping.clone())?; + let overlapping_to_list = list_from_list_view(overlapping.clone(), &mut ctx)?; let overlapping_back = list_view_from_list(overlapping_to_list, &mut ctx)?; assert_arrays_eq!(overlapping, overlapping_back); Ok(()) @@ -502,7 +511,7 @@ mod tests { let offsets = buffer![0u32, 1, 2, 3].into_array(); let single_elem_list = ListArray::try_new(elements, offsets, Validity::NonNullable)?; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let list_view = list_view_from_list(single_elem_list.clone(), &mut ctx)?; assert_eq!(list_view.len(), 3); @@ -511,7 +520,7 @@ mod tests { assert_arrays_eq!(expected_sizes, list_view.sizes().clone()); // Round-trip. - let converted_back = list_from_list_view(list_view)?; + let converted_back = list_from_list_view(list_view, &mut ctx)?; assert_arrays_eq!(single_elem_list, converted_back); Ok(()) } @@ -523,7 +532,7 @@ mod tests { let offsets = buffer![0u32, 2, 2, 3, 3, 6].into_array(); let mixed_list = ListArray::try_new(elements, offsets, Validity::NonNullable)?; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let list_view = list_view_from_list(mixed_list.clone(), &mut ctx)?; assert_eq!(list_view.len(), 5); @@ -532,7 +541,7 @@ mod tests { assert_arrays_eq!(expected_sizes, list_view.sizes().clone()); // Round-trip. - let converted_back = list_from_list_view(list_view)?; + let converted_back = list_from_list_view(list_view, &mut ctx)?; assert_arrays_eq!(mixed_list, converted_back); Ok(()) } @@ -540,7 +549,10 @@ mod tests { #[test] fn test_recursive_simple_listview() -> VortexResult<()> { let list_view = create_basic_listview(); - let result = recursive_list_from_list_view(list_view.clone().into_array())?; + let result = recursive_list_from_list_view( + list_view.clone().into_array(), + &mut SESSION.create_execution_ctx(), + )?; assert_eq!(result.len(), list_view.len()); assert_arrays_eq!(list_view.into_array(), result); @@ -574,7 +586,10 @@ mod tests { .with_zero_copy_to_list(true) }; - let result = recursive_list_from_list_view(outer_listview.clone().into_array())?; + let result = recursive_list_from_list_view( + outer_listview.clone().into_array(), + &mut SESSION.create_execution_ctx(), + )?; assert_eq!(result.len(), 2); assert_arrays_eq!(outer_listview.into_array(), result); @@ -593,7 +608,10 @@ mod tests { Validity::NonNullable, )?; - let result = recursive_list_from_list_view(struct_array.clone().into_array())?; + let result = recursive_list_from_list_view( + struct_array.clone().into_array(), + &mut SESSION.create_execution_ctx(), + )?; assert_eq!(result.len(), 4); assert_arrays_eq!(struct_array.into_array(), result); @@ -635,7 +653,10 @@ mod tests { let fixed_list = FixedSizeListArray::new(chunked_listviews.into_array(), 1, Validity::NonNullable, 2); - let result = recursive_list_from_list_view(fixed_list.clone().into_array())?; + let result = recursive_list_from_list_view( + fixed_list.clone().into_array(), + &mut SESSION.create_execution_ctx(), + )?; assert_eq!(result.len(), 2); assert_arrays_eq!(fixed_list.into_array(), result); @@ -676,7 +697,10 @@ mod tests { .with_zero_copy_to_list(true) }; - let result = recursive_list_from_list_view(outer_listview.clone().into_array())?; + let result = recursive_list_from_list_view( + outer_listview.clone().into_array(), + &mut SESSION.create_execution_ctx(), + )?; assert_eq!(result.len(), 2); assert_arrays_eq!(outer_listview.into_array(), result); @@ -687,7 +711,7 @@ mod tests { fn test_recursive_primitive_unchanged() -> VortexResult<()> { let prim = buffer![1i32, 2, 3].into_array(); let prim_clone = prim.clone(); - let result = recursive_list_from_list_view(prim)?; + let result = recursive_list_from_list_view(prim, &mut SESSION.create_execution_ctx())?; assert!(ArrayRef::ptr_eq(&result, &prim_clone)); Ok(()) @@ -695,8 +719,9 @@ mod tests { #[test] fn test_recursive_mixed_listview_and_list() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); let listview = create_basic_listview(); - let list = list_from_list_view(listview.clone())?; + let list = list_from_list_view(listview.clone(), &mut ctx)?; let struct_array = StructArray::try_new( FieldNames::from(["listview_field", "list_field"]), @@ -705,7 +730,7 @@ mod tests { Validity::NonNullable, )?; - let result = recursive_list_from_list_view(struct_array.clone().into_array())?; + let result = recursive_list_from_list_view(struct_array.clone().into_array(), &mut ctx)?; assert_eq!(result.len(), 4); assert_arrays_eq!(struct_array.into_array(), result); @@ -727,7 +752,7 @@ mod tests { // `try_new` sets `is_zero_copy_to_list: false`. assert!(!list_view.is_zero_copy_to_list()); - let list_array = list_from_list_view(list_view)?; + let list_array = list_from_list_view(list_view, &mut SESSION.create_execution_ctx())?; assert_eq!(list_array.len(), 0); Ok(()) } diff --git a/vortex-array/src/arrays/listview/rebuild.rs b/vortex-array/src/arrays/listview/rebuild.rs index 2bcb4ef0c1f..7ef752e4dcb 100644 --- a/vortex-array/src/arrays/listview/rebuild.rs +++ b/vortex-array/src/arrays/listview/rebuild.rs @@ -6,11 +6,9 @@ use vortex_buffer::BufferMut; use vortex_error::VortexExpect; use vortex_error::VortexResult; +use crate::Canonical; +use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; -#[expect(deprecated)] -use crate::ToCanonical as _; -use crate::VortexSessionExecute; use crate::arrays::ConstantArray; use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; @@ -99,16 +97,20 @@ pub enum ListViewRebuildMode { impl ListViewArray { /// Rebuilds the [`ListViewArray`] according to the specified mode. - pub fn rebuild(&self, mode: ListViewRebuildMode) -> VortexResult { + pub fn rebuild( + &self, + mode: ListViewRebuildMode, + ctx: &mut ExecutionCtx, + ) -> VortexResult { if self.is_empty() { // SAFETY: An empty array is trivially zero-copyable to a `ListArray`. return Ok(unsafe { self.clone().with_zero_copy_to_list(true) }); } match mode { - ListViewRebuildMode::MakeZeroCopyToList => self.rebuild_zero_copy_to_list(), - ListViewRebuildMode::TrimElements => self.rebuild_trim_elements(), - ListViewRebuildMode::MakeExact => self.rebuild_make_exact(), + ListViewRebuildMode::MakeZeroCopyToList => self.rebuild_zero_copy_to_list(ctx), + ListViewRebuildMode::TrimElements => self.rebuild_trim_elements(ctx), + ListViewRebuildMode::MakeExact => self.rebuild_make_exact(ctx), ListViewRebuildMode::OverlapCompression => unimplemented!("Does P=NP?"), } } @@ -120,7 +122,7 @@ impl ListViewArray { /// a [`ListArray`]. /// /// [`ListArray`]: crate::arrays::ListArray - fn rebuild_zero_copy_to_list(&self) -> VortexResult { + fn rebuild_zero_copy_to_list(&self, ctx: &mut ExecutionCtx) -> VortexResult { if self.is_zero_copy_to_list() { // Note that since everything in `ListViewArray` is `Arc`ed, this is quite cheap. return Ok(self.clone()); @@ -138,10 +140,10 @@ impl ListViewArray { // for sizes as well. match_each_unsigned_integer_ptype!(sizes_ptype.to_unsigned(), |S| { match offsets_ptype.to_unsigned() { - PType::U8 => self.naive_rebuild::(), - PType::U16 => self.naive_rebuild::(), - PType::U32 => self.naive_rebuild::(), - PType::U64 => self.naive_rebuild::(), + PType::U8 => self.naive_rebuild::(ctx), + PType::U16 => self.naive_rebuild::(ctx), + PType::U32 => self.naive_rebuild::(ctx), + PType::U64 => self.naive_rebuild::(ctx), _ => unreachable!("invalid offsets PType"), } }) @@ -152,9 +154,9 @@ impl ListViewArray { /// list size. fn naive_rebuild( &self, + ctx: &mut ExecutionCtx, ) -> VortexResult { - #[expect(deprecated)] - let sizes_canonical = self.sizes().to_primitive(); + let sizes_canonical = self.sizes().clone().execute::(ctx)?; let sizes_canonical = sizes_canonical.reinterpret_cast(sizes_canonical.ptype().to_unsigned()); let total: u64 = sizes_canonical @@ -163,9 +165,9 @@ impl ListViewArray { .map(|s| (*s).as_() as u64) .sum(); if Self::should_use_take(total, self.len()) { - self.rebuild_with_take::() + self.rebuild_with_take::(ctx) } else { - self.rebuild_list_by_list::() + self.rebuild_list_by_list::(ctx) } } @@ -188,17 +190,16 @@ impl ListViewArray { /// `BufferMut`, perform a single `take`. fn rebuild_with_take( &self, + ctx: &mut ExecutionCtx, ) -> VortexResult { let new_offset_ptype = rebuilt_offset_ptype(self.offsets().dtype().as_ptype()); let size_ptype = self.sizes().dtype().as_ptype(); - #[expect(deprecated)] - let offsets_canonical = self.offsets().to_primitive(); + let offsets_canonical = self.offsets().clone().execute::(ctx)?; let offsets_canonical = offsets_canonical.reinterpret_cast(offsets_canonical.ptype().to_unsigned()); let offsets_slice = offsets_canonical.as_slice::(); - #[expect(deprecated)] - let sizes_canonical = self.sizes().to_primitive(); + let sizes_canonical = self.sizes().clone().execute::(ctx)?; let sizes_canonical = sizes_canonical.reinterpret_cast(sizes_canonical.ptype().to_unsigned()); let sizes_slice = sizes_canonical.as_slice::(); @@ -209,9 +210,14 @@ impl ListViewArray { let mut new_sizes = BufferMut::::with_capacity(len); let mut take_indices = BufferMut::::with_capacity(self.elements().len()); + // Resolve validity to a mask once instead of probing it per row: `execute_is_valid` + // executes a scalar on every call for array-backed validity, which is O(len) work repeated + // `len` times. + let validity = self.validity()?.execute_mask(len, ctx)?; + let mut n_elements = NewOffset::zero(); for index in 0..len { - if !self.validity()?.is_valid(index)? { + if !validity.value(index) { new_offsets.push(n_elements); new_sizes.push(S::zero()); continue; @@ -250,6 +256,7 @@ impl ListViewArray { /// the relevant range and `extend_from_array` into a typed builder. fn rebuild_list_by_list( &self, + ctx: &mut ExecutionCtx, ) -> VortexResult { let element_dtype = self .dtype() @@ -259,13 +266,11 @@ impl ListViewArray { let new_offset_ptype = rebuilt_offset_ptype(self.offsets().dtype().as_ptype()); let size_ptype = self.sizes().dtype().as_ptype(); - #[expect(deprecated)] - let offsets_canonical = self.offsets().to_primitive(); + let offsets_canonical = self.offsets().clone().execute::(ctx)?; let offsets_canonical = offsets_canonical.reinterpret_cast(offsets_canonical.ptype().to_unsigned()); let offsets_slice = offsets_canonical.as_slice::(); - #[expect(deprecated)] - let sizes_canonical = self.sizes().to_primitive(); + let sizes_canonical = self.sizes().clone().execute::(ctx)?; let sizes_canonical = sizes_canonical.reinterpret_cast(sizes_canonical.ptype().to_unsigned()); let sizes_slice = sizes_canonical.as_slice::(); @@ -280,11 +285,10 @@ impl ListViewArray { let mut new_sizes = BufferMut::::with_capacity(len); // Canonicalize the elements up front as we will be slicing the elements quite a lot. - #[expect(deprecated)] let elements_canonical = self .elements() - .to_canonical() - .vortex_expect("canonicalize elements for rebuild") + .clone() + .execute::(ctx)? .into_array(); // Note that we do not know what the exact capacity should be of the new elements since @@ -292,9 +296,12 @@ impl ListViewArray { let mut new_elements_builder = builder_with_capacity(element_dtype.as_ref(), self.elements().len()); + // Resolve validity to a mask once instead of probing it per row (see `rebuild_with_take`). + let validity = self.validity()?.execute_mask(len, ctx)?; + let mut n_elements = NewOffset::zero(); for index in 0..len { - if !self.validity()?.is_valid(index)? { + if !validity.value(index) { // For NULL lists, place them after the previous item's data to maintain the // no-overlap invariant for zero-copy to `ListArray` arrays. new_offsets.push(n_elements); @@ -347,9 +354,8 @@ impl ListViewArray { /// Rebuilds a [`ListViewArray`] by trimming any unused / unreferenced leading and trailing /// elements, which is defined as a contiguous run of values in the `elements` array that are /// not referenced by any views in the corresponding [`ListViewArray`]. - fn rebuild_trim_elements(&self) -> VortexResult { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); - let (start, end) = self.referenced_element_bounds(&mut ctx)?; + fn rebuild_trim_elements(&self, ctx: &mut ExecutionCtx) -> VortexResult { + let (start, end) = self.referenced_element_bounds(ctx)?; // SAFETY: we calculated valid start and end bounds unsafe { self.trim_elements(start, end) } @@ -395,13 +401,13 @@ impl ListViewArray { }) } - fn rebuild_make_exact(&self) -> VortexResult { + fn rebuild_make_exact(&self, ctx: &mut ExecutionCtx) -> VortexResult { if self.is_zero_copy_to_list() { - self.rebuild_trim_elements() + self.rebuild_trim_elements(ctx) } else { // When we completely rebuild the `ListViewArray`, we get the benefit that we also trim // any leading and trailing garbage data. - self.rebuild_zero_copy_to_list() + self.rebuild_zero_copy_to_list(ctx) } } } @@ -411,11 +417,9 @@ mod tests { use vortex_buffer::BitBuffer; use vortex_error::VortexResult; + use super::super::tests::common::SESSION; use super::ListViewRebuildMode; use crate::IntoArray; - use crate::LEGACY_SESSION; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; @@ -435,7 +439,8 @@ mod tests { let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - let flattened = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList)?; + let mut ctx = SESSION.create_execution_ctx(); + let flattened = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList, &mut ctx)?; // After flatten: elements should be [A, B, C, B, C] = [1, 2, 3, 2, 3] // Lists should be sequential with no overlaps @@ -478,13 +483,14 @@ mod tests { let listview = ListViewArray::new(elements, offsets, sizes, validity); - let flattened = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList)?; + let mut ctx = SESSION.create_execution_ctx(); + let flattened = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList, &mut ctx)?; // Verify nullability is preserved assert_eq!(flattened.dtype().nullability(), Nullability::Nullable); - assert!(flattened.validity()?.is_valid(0)?); - assert!(!flattened.validity()?.is_valid(1)?); - assert!(flattened.validity()?.is_valid(2)?); + assert!(flattened.validity()?.execute_is_valid(0, &mut ctx)?); + assert!(!flattened.validity()?.execute_is_valid(1, &mut ctx)?); + assert!(flattened.validity()?.execute_is_valid(2, &mut ctx)?); // Verify valid lists contain correct data assert_arrays_eq!( @@ -515,7 +521,8 @@ mod tests { let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements)?; + let mut ctx = SESSION.create_execution_ctx(); + let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements, &mut ctx)?; // After trimming: elements should be [A, B, _, C, D] = [1, 2, 97, 3, 4]. assert_eq!(trimmed.elements().len(), 5); @@ -538,12 +545,11 @@ mod tests { ); // Note that element at index 2 (97) is preserved as a gap. - #[expect(deprecated)] - let all_elements = trimmed.elements().to_primitive(); - assert_eq!( - all_elements.execute_scalar(2, &mut LEGACY_SESSION.create_execution_ctx())?, - 97i32.into() - ); + let all_elements = trimmed + .elements() + .clone() + .execute::(&mut ctx)?; + assert_eq!(all_elements.execute_scalar(2, &mut ctx)?, 97i32.into()); Ok(()) } @@ -562,7 +568,8 @@ mod tests { let listview = ListViewArray::new(elements, offsets, sizes, validity); // First rebuild to make it zero-copy-to-list - let rebuilt = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList)?; + let mut ctx = SESSION.create_execution_ctx(); + let rebuilt = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList, &mut ctx)?; assert!(rebuilt.is_zero_copy_to_list()); // Verify NULL items have correct offsets (should not reuse previous offsets) @@ -580,13 +587,13 @@ mod tests { // Now rebuild with MakeExact (which calls naive_rebuild then trim_elements) // This should not panic (issue #5412) - let exact = rebuilt.rebuild(ListViewRebuildMode::MakeExact)?; + let exact = rebuilt.rebuild(ListViewRebuildMode::MakeExact, &mut ctx)?; // Verify the result is still valid - assert!(exact.is_valid(0, &mut LEGACY_SESSION.create_execution_ctx())?); - assert!(exact.is_valid(1, &mut LEGACY_SESSION.create_execution_ctx())?); - assert!(!exact.is_valid(2, &mut LEGACY_SESSION.create_execution_ctx())?); - assert!(!exact.is_valid(3, &mut LEGACY_SESSION.create_execution_ctx())?); + assert!(exact.is_valid(0, &mut ctx)?); + assert!(exact.is_valid(1, &mut ctx)?); + assert!(!exact.is_valid(2, &mut ctx)?); + assert!(!exact.is_valid(3, &mut ctx)?); // Verify data is preserved assert_arrays_eq!( @@ -615,7 +622,8 @@ mod tests { let sizes = PrimitiveArray::from_iter(vec![2u16, 2]).into_array(); let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements)?; + let mut ctx = SESSION.create_execution_ctx(); + let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements, &mut ctx)?; assert_arrays_eq!( trimmed.list_elements_at(1)?, PrimitiveArray::from_iter([30i32, 40]) @@ -635,7 +643,8 @@ mod tests { let sizes = PrimitiveArray::from_iter(vec![70_000u32, 2]).into_array(); let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements)?; + let mut ctx = SESSION.create_execution_ctx(); + let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements, &mut ctx)?; assert_arrays_eq!( trimmed.list_elements_at(1)?, PrimitiveArray::from_iter([30i32, 40]) @@ -655,7 +664,8 @@ mod tests { let sizes = PrimitiveArray::from_iter(vec![3i16, 2]).into_array(); let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - let rebuilt = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList)?; + let mut ctx = SESSION.create_execution_ctx(); + let rebuilt = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList, &mut ctx)?; // Values: [1,2,3] and [2,3]. assert_arrays_eq!( @@ -697,7 +707,8 @@ mod tests { let sizes = PrimitiveArray::from_iter(vec![46u8, 10]).into_array(); let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements)?; + let mut ctx = SESSION.create_execution_ctx(); + let trimmed = listview.rebuild(ListViewRebuildMode::TrimElements, &mut ctx)?; // min(offsets) = 0, so nothing to trim; output should equal input. assert_arrays_eq!(trimmed, listview); diff --git a/vortex-array/src/arrays/listview/tests/common.rs b/vortex-array/src/arrays/listview/tests/common.rs index dc8bc94fb18..be89b586209 100644 --- a/vortex-array/src/arrays/listview/tests/common.rs +++ b/vortex-array/src/arrays/listview/tests/common.rs @@ -3,14 +3,23 @@ //! Common test utilities for ListView tests. +use std::sync::LazyLock; + use vortex_buffer::buffer; +use vortex_session::VortexSession; use crate::IntoArray; use crate::arrays::BoolArray; use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; +use crate::session::ArraySession; use crate::validity::Validity; +/// A shared session for `ListView` tests, used to create execution contexts via +/// [`create_execution_ctx`](crate::VortexSessionExecute::create_execution_ctx). +pub static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + /// Creates a basic ListView for testing: [[0,1,2], [3,4], [5,6], [7,8,9]] pub fn create_basic_listview() -> ListViewArray { let elements = buffer![0i32, 1, 2, 3, 4, 5, 6, 7, 8, 9].into_array(); diff --git a/vortex-array/src/arrays/listview/tests/operations.rs b/vortex-array/src/arrays/listview/tests/operations.rs index 235caf53caa..c911b9ba7a5 100644 --- a/vortex-array/src/arrays/listview/tests/operations.rs +++ b/vortex-array/src/arrays/listview/tests/operations.rs @@ -5,11 +5,13 @@ use std::sync::Arc; use rstest::rstest; use vortex_buffer::buffer; +use vortex_error::VortexResult; use vortex_mask::Mask; use super::common::create_basic_listview; use super::common::create_large_listview; use super::common::create_nullable_listview; +use crate::ArrayRef; use crate::IntoArray; use crate::LEGACY_SESSION; #[expect(deprecated)] @@ -382,6 +384,100 @@ fn test_cast_large_dataset() { } } +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Zip tests +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[test] +fn test_zip_widens_false_element_nullability() -> VortexResult<()> { + // [[1, 2], [3], [4]] + let if_true = ListViewArray::new( + buffer![1i32, 2, 3, 4].into_array(), + buffer![0u32, 2, 3].into_array(), + buffer![2u32, 1, 1].into_array(), + Validity::NonNullable, + ) + .into_array(); + // [[10, null], [30], [40]] + let if_false = ListViewArray::new( + PrimitiveArray::from_option_iter([Some(10i32), None, Some(30), Some(40)]).into_array(), + buffer![0u32, 2, 3].into_array(), + buffer![2u32, 1, 1].into_array(), + Validity::NonNullable, + ) + .into_array(); + let mask = Mask::from_iter([false, true, false]); + + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut LEGACY_SESSION.create_execution_ctx())?; + assert!(result.is::()); + assert_eq!( + result.dtype(), + &DType::List( + Arc::new(DType::Primitive(PType::I32, Nullability::Nullable)), + Nullability::NonNullable, + ) + ); + + // [[10, null], [3], [40]] + let expected = ListViewArray::new( + PrimitiveArray::from_option_iter([Some(10i32), None, Some(3), Some(40)]).into_array(), + buffer![0u32, 2, 3].into_array(), + buffer![2u32, 1, 1].into_array(), + Validity::NonNullable, + ) + .into_array(); + assert_arrays_eq!(result, expected); + Ok(()) +} + +#[test] +fn test_zip_widens_true_element_nullability() -> VortexResult<()> { + // [[1, null], [3], [4]] + let if_true = ListViewArray::new( + PrimitiveArray::from_option_iter([Some(1i32), None, Some(3), Some(4)]).into_array(), + buffer![0u32, 2, 3].into_array(), + buffer![2u32, 1, 1].into_array(), + Validity::NonNullable, + ) + .into_array(); + // [[10], [20], [30]] + let if_false = ListViewArray::new( + buffer![10i32, 20, 30].into_array(), + buffer![0u32, 1, 2].into_array(), + buffer![1u32, 1, 1].into_array(), + Validity::NonNullable, + ) + .into_array(); + let mask = Mask::from_iter([true, false, true]); + + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut LEGACY_SESSION.create_execution_ctx())?; + assert!(result.is::()); + assert_eq!( + result.dtype(), + &DType::List( + Arc::new(DType::Primitive(PType::I32, Nullability::Nullable)), + Nullability::NonNullable, + ) + ); + + // [[1, null], [20], [4]] + let expected = ListViewArray::new( + PrimitiveArray::from_option_iter([Some(1i32), None, Some(20), Some(4)]).into_array(), + buffer![0u32, 2, 3].into_array(), + buffer![2u32, 1, 1].into_array(), + Validity::NonNullable, + ) + .into_array(); + assert_arrays_eq!(result, expected); + Ok(()) +} + //////////////////////////////////////////////////////////////////////////////////////////////////// // Constant tests //////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/vortex-array/src/arrays/listview/vtable/kernel.rs b/vortex-array/src/arrays/listview/vtable/kernel.rs index f6ceca284bf..1ad98f62a33 100644 --- a/vortex-array/src/arrays/listview/vtable/kernel.rs +++ b/vortex-array/src/arrays/listview/vtable/kernel.rs @@ -4,6 +4,9 @@ use crate::arrays::ListView; use crate::kernel::ParentKernelSet; use crate::scalar_fn::fns::cast::CastExecuteAdaptor; +use crate::scalar_fn::fns::zip::ZipExecuteAdaptor; -pub(super) const PARENT_KERNELS: ParentKernelSet = - ParentKernelSet::new(&[ParentKernelSet::lift(&CastExecuteAdaptor(ListView))]); +pub(super) const PARENT_KERNELS: ParentKernelSet = ParentKernelSet::new(&[ + ParentKernelSet::lift(&CastExecuteAdaptor(ListView)), + ParentKernelSet::lift(&ZipExecuteAdaptor(ListView)), +]); diff --git a/vortex-array/src/arrays/listview/vtable/mod.rs b/vortex-array/src/arrays/listview/vtable/mod.rs index ddfa4aa0e6b..26e54b2d051 100644 --- a/vortex-array/src/arrays/listview/vtable/mod.rs +++ b/vortex-array/src/arrays/listview/vtable/mod.rs @@ -17,9 +17,9 @@ use vortex_session::registry::CachedId; use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayView; @@ -59,13 +59,13 @@ pub struct ListViewMetadata { } impl ArrayHash for ListViewData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.is_zero_copy_to_list().hash(state); } } impl ArrayEq for ListViewData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.is_zero_copy_to_list() == other.is_zero_copy_to_list() } } diff --git a/vortex-array/src/arrays/masked/tests.rs b/vortex-array/src/arrays/masked/tests.rs index 2721ba9b519..b26d101eb33 100644 --- a/vortex-array/src/arrays/masked/tests.rs +++ b/vortex-array/src/arrays/masked/tests.rs @@ -134,7 +134,7 @@ fn test_masked_child_preserves_length(#[case] validity: Validity) { array .validity() .vortex_expect("masked validity should be derivable") - .mask_eq(&validity, &mut ctx) + .mask_eq(&validity, array.len(), &mut ctx) .unwrap(), ); } diff --git a/vortex-array/src/arrays/masked/vtable/mod.rs b/vortex-array/src/arrays/masked/vtable/mod.rs index 257448c6eec..4409c8ff3c6 100644 --- a/vortex-array/src/arrays/masked/vtable/mod.rs +++ b/vortex-array/src/arrays/masked/vtable/mod.rs @@ -20,9 +20,9 @@ use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; use crate::Canonical; +use crate::EqMode; use crate::IntoArray; use crate::LEGACY_SESSION; -use crate::Precision; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayId; @@ -51,11 +51,11 @@ pub type MaskedArray = Array; pub struct Masked; impl ArrayHash for MaskedData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl ArrayEq for MaskedData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } diff --git a/vortex-array/src/arrays/mod.rs b/vortex-array/src/arrays/mod.rs index 212f4bbe619..148cf209555 100644 --- a/vortex-array/src/arrays/mod.rs +++ b/vortex-array/src/arrays/mod.rs @@ -50,6 +50,10 @@ pub mod fixed_size_list; pub use fixed_size_list::FixedSizeList; pub use fixed_size_list::FixedSizeListArray; +pub mod interleave; +pub use interleave::Interleave; +pub use interleave::InterleaveArray; + pub mod list; pub use list::List; pub use list::ListArray; diff --git a/vortex-array/src/arrays/patched/compute/compare.rs b/vortex-array/src/arrays/patched/compute/compare.rs index c7d879323cb..0f1e6add00f 100644 --- a/vortex-array/src/arrays/patched/compute/compare.rs +++ b/vortex-array/src/arrays/patched/compute/compare.rs @@ -55,9 +55,10 @@ impl CompareKernel for Patched { let validity = child_to_validity(result.slots()[0].as_ref(), result.dtype().nullability()); let len = result.len(); - let BoolDataParts { bits, offset, len } = result.into_data().into_parts(len); + let BoolDataParts { bits, meta } = result.into_data().into_parts(len); - let mut bits = BitBufferMut::from_buffer(bits.unwrap_host().into_mut(), offset, len); + let mut bits = + BitBufferMut::from_buffer(bits.unwrap_host().into_mut(), meta.offset(), meta.len()); let lane_offsets = lhs.lane_offsets().clone().execute::(ctx)?; let indices = lhs.patch_indices().clone().execute::(ctx)?; diff --git a/vortex-array/src/arrays/patched/vtable/mod.rs b/vortex-array/src/arrays/patched/vtable/mod.rs index f35a13600c5..4ba5bc542e1 100644 --- a/vortex-array/src/arrays/patched/vtable/mod.rs +++ b/vortex-array/src/arrays/patched/vtable/mod.rs @@ -21,10 +21,10 @@ use vortex_session::registry::CachedId; use crate::ArrayRef; use crate::Canonical; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; use crate::IntoArray; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayParts; @@ -82,14 +82,14 @@ pub struct PatchedMetadata { } impl ArrayHash for PatchedData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.offset.hash(state); self.n_lanes.hash(state); } } impl ArrayEq for PatchedData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.offset == other.offset && self.n_lanes == other.n_lanes } } diff --git a/vortex-array/src/arrays/primitive/array/mod.rs b/vortex-array/src/arrays/primitive/array/mod.rs index 7e3dd9b4ce7..e22fc9a4336 100644 --- a/vortex-array/src/arrays/primitive/array/mod.rs +++ b/vortex-array/src/arrays/primitive/array/mod.rs @@ -549,7 +549,7 @@ impl PrimitiveData { Validity::Array(is_valid) => { #[expect(deprecated)] let bool_array = is_valid.to_bool(); - let bool_buffer = bool_array.to_bit_buffer(); + let bool_buffer = bool_array.bit_buffer_view(); let mut bytes = ByteBufferMut::zeroed_aligned(n_rows * byte_width, alignment); for (i, valid_i) in bool_buffer.set_indices().enumerate() { bytes[valid_i * byte_width..(valid_i + 1) * byte_width] diff --git a/vortex-array/src/arrays/primitive/compute/mod.rs b/vortex-array/src/arrays/primitive/compute/mod.rs index 867ddf69d03..bd59efed20b 100644 --- a/vortex-array/src/arrays/primitive/compute/mod.rs +++ b/vortex-array/src/arrays/primitive/compute/mod.rs @@ -8,6 +8,7 @@ mod mask; pub(crate) mod rules; mod slice; mod take; +mod zip; #[cfg(test)] mod tests { diff --git a/vortex-array/src/arrays/primitive/compute/slice.rs b/vortex-array/src/arrays/primitive/compute/slice.rs index 1830ef3f8c6..4775adba749 100644 --- a/vortex-array/src/arrays/primitive/compute/slice.rs +++ b/vortex-array/src/arrays/primitive/compute/slice.rs @@ -11,19 +11,21 @@ use crate::array::ArrayView; use crate::arrays::Primitive; use crate::arrays::PrimitiveArray; use crate::arrays::slice::SliceReduce; -use crate::dtype::NativePType; -use crate::match_each_native_ptype; impl SliceReduce for Primitive { fn slice(array: ArrayView<'_, Self>, range: Range) -> VortexResult> { - let result = match_each_native_ptype!(array.ptype(), |T| { - PrimitiveArray::from_buffer_handle( - array.buffer_handle().slice_typed::(range.clone()), - T::PTYPE, - array.validity()?.slice(range)?, - ) - .into_array() - }); - Ok(Some(result)) + let byte_width = array.ptype().byte_width(); + let byte_range = range.start * byte_width..range.end * byte_width; + let values = array.buffer_handle().slice(byte_range); + let validity = array.validity()?.slice(range)?; + + // SAFETY: + //slicing an existing PrimitiveArray on element boundaries preserves the buffer + // alignment, ptype, length, and validity invariants. + let array = unsafe { + PrimitiveArray::new_unchecked_from_handle(values, array.ptype(), validity).into_array() + }; + + Ok(Some(array)) } } diff --git a/vortex-array/src/arrays/primitive/compute/zip.rs b/vortex-array/src/arrays/primitive/compute/zip.rs new file mode 100644 index 00000000000..0dcb7298460 --- /dev/null +++ b/vortex-array/src/arrays/primitive/compute/zip.rs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::mem::MaybeUninit; + +use vortex_buffer::BufferMut; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_mask::Mask; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::array::ArrayView; +use crate::arrays::Primitive; +use crate::arrays::PrimitiveArray; +use crate::dtype::NativePType; +use crate::match_each_native_ptype; +use crate::scalar_fn::fns::zip::ZipKernel; +use crate::scalar_fn::fns::zip::zip_validity; + +/// A dedicated primitive zip kernel that selects values branchlessly per row. +/// +/// The generic zip path copies runs of `if_true`/`if_false` between mask boundaries, which is fast +/// for clustered masks but degrades to per-element work on fragmented masks. This kernel instead +/// walks the mask as 64-bit chunks and blends both sides per row without a data-dependent branch, +/// so the inner loop stays branch-free and auto-vectorizable regardless of mask shape. +impl ZipKernel for Primitive { + fn zip( + if_true: ArrayView<'_, Primitive>, + if_false: &ArrayRef, + mask: &ArrayRef, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { + let Some(if_false) = if_false.as_opt::() else { + return Ok(None); + }; + + if if_true.ptype() != if_false.ptype() { + vortex_bail!( + "zip requires if_true and if_false to share a primitive type, got {} and {}", + if_true.ptype(), + if_false.ptype() + ); + } + + // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics. + let mask = mask.try_to_mask_fill_null_false(ctx)?; + match &mask { + // Defer trivial masks to the generic zip, which just casts the surviving side. + Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None), + Mask::Values(_) => {} + } + + let validity = zip_validity(if_true.validity()?, if_false.validity()?, &mask)?; + + // TODO(perf): inspect the mask's true_count (and validity) to special-case heavily-skewed + // masks. When one side dominates (true_count near 0 or near len), it is cheaper to bulk + // copy — or mutate in place, if the dominant side is uniquely owned — that side's values + // and validity, then conditionally pull in only the minority rows from the other side, + // rather than blending every row. + let array = match_each_native_ptype!(if_true.ptype(), |T| { + let values = + select_values::(if_true.as_slice::(), if_false.as_slice::(), &mask); + PrimitiveArray::new(values.freeze(), validity).into_array() + }); + Ok(Some(array)) + } +} + +/// Branchlessly blend `if_true` and `if_false` per row into a fresh value buffer. +fn select_values( + true_values: &[T], + false_values: &[T], + mask: &Mask, +) -> BufferMut { + let len = true_values.len(); + let mut out = BufferMut::::with_capacity(len); + { + let out_slice = out.spare_capacity_mut(); + + let mask_bits = mask + .values() + .vortex_expect("mask is Mask::Values") + .bit_buffer(); + // TODO(perf): `unaligned_chunks` is a faster single-buffer iterator than `chunks`; switch to + // it here, handling its lead/trailing padding. + let chunks = mask_bits.chunks(); + + let mut base = 0; + for word in chunks.iter() { + let end = base + 64; + select_block( + word, + &true_values[base..end], + &false_values[base..end], + &mut out_slice[base..end], + ); + base = end; + } + + let remainder = chunks.remainder_len(); + if remainder > 0 { + let end = base + remainder; + select_block( + chunks.remainder_bits(), + &true_values[base..end], + &false_values[base..end], + &mut out_slice[base..end], + ); + } + } + + // SAFETY: `select_block` initialized every slot covered by the chunks plus remainder, i.e. `len`. + unsafe { out.set_len(len) }; + out +} + +/// Blend one 64-bit mask chunk's worth of rows: bit `j` (LSB-first) keeps `true_values[j]`, an unset +/// bit keeps `false_values[j]`. Slices are trimmed to the output length up front so the compiler can +/// elide bounds checks and lower the body to a vector blend / conditional move. +#[inline] +fn select_block( + word: u64, + true_values: &[T], + false_values: &[T], + out: &mut [MaybeUninit], +) { + let n = out.len(); + let true_values = &true_values[..n]; + let false_values = &false_values[..n]; + for j in 0..n { + let pick = (word >> j) & 1 == 1; + out[j].write(if pick { + true_values[j] + } else { + false_values[j] + }); + } +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::cast_possible_truncation, + reason = "test fixtures use small indices that fit the target widths" + )] + + use vortex_error::VortexResult; + use vortex_mask::Mask; + + use crate::ArrayRef; + use crate::IntoArray; + use crate::LEGACY_SESSION; + use crate::VortexSessionExecute; + use crate::arrays::Primitive; + use crate::arrays::PrimitiveArray; + use crate::assert_arrays_eq; + use crate::builtins::ArrayBuiltins; + + /// The branchless kernel must agree with the scalar reference across the chunk boundary (index + /// 63/64) and the trailing remainder, for non-nullable inputs. + #[test] + fn zip_nonnull_spans_mask_chunks() -> VortexResult<()> { + let len = 150usize; + let if_true = PrimitiveArray::from_iter(0..len as i64).into_array(); + let if_false = PrimitiveArray::from_iter((0..len as i64).map(|i| 1_000 + i)).into_array(); + + let bits: Vec = (0..len).map(|i| i.is_multiple_of(3) || i == 64).collect(); + let mask = Mask::from_iter(bits.iter().copied()); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + assert!(result.is::()); + + let expected = PrimitiveArray::from_iter( + (0..len).map(|i| if bits[i] { i as i64 } else { 1_000 + i as i64 }), + ) + .into_array(); + assert_arrays_eq!(result, expected); + Ok(()) + } + + /// With `Validity::Array` on both sides the kernel must select values and validity from the + /// chosen side across the chunk boundary. + #[test] + fn zip_nullable_selects_values_and_validity() -> VortexResult<()> { + let len = 130usize; + let if_true = + PrimitiveArray::from_option_iter((0..len as i64).map(|i| (i % 4 != 0).then_some(i))) + .into_array(); + let if_false = PrimitiveArray::from_option_iter( + (0..len as i64).map(|i| (i % 5 != 0).then_some(1_000 + i)), + ) + .into_array(); + + let bits: Vec = (0..len).map(|i| i.is_multiple_of(2)).collect(); + let mask = Mask::from_iter(bits.iter().copied()); + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + assert!(result.is::()); + + let expected = PrimitiveArray::from_option_iter((0..len).map(|i| { + let v = i as i64; + if bits[i] { + (!i.is_multiple_of(4)).then_some(v) + } else { + (!i.is_multiple_of(5)).then_some(1_000 + v) + } + })) + .into_array(); + assert_arrays_eq!(result, expected); + Ok(()) + } +} diff --git a/vortex-array/src/arrays/primitive/vtable/kernel.rs b/vortex-array/src/arrays/primitive/vtable/kernel.rs index 03b6cfa2b98..341d2f21272 100644 --- a/vortex-array/src/arrays/primitive/vtable/kernel.rs +++ b/vortex-array/src/arrays/primitive/vtable/kernel.rs @@ -7,10 +7,12 @@ use crate::kernel::ParentKernelSet; use crate::scalar_fn::fns::between::BetweenExecuteAdaptor; use crate::scalar_fn::fns::cast::CastExecuteAdaptor; use crate::scalar_fn::fns::fill_null::FillNullExecuteAdaptor; +use crate::scalar_fn::fns::zip::ZipExecuteAdaptor; pub(super) const PARENT_KERNELS: ParentKernelSet = ParentKernelSet::new(&[ ParentKernelSet::lift(&BetweenExecuteAdaptor(Primitive)), ParentKernelSet::lift(&CastExecuteAdaptor(Primitive)), ParentKernelSet::lift(&FillNullExecuteAdaptor(Primitive)), ParentKernelSet::lift(&TakeExecuteAdaptor(Primitive)), + ParentKernelSet::lift(&ZipExecuteAdaptor(Primitive)), ]); diff --git a/vortex-array/src/arrays/primitive/vtable/mod.rs b/vortex-array/src/arrays/primitive/vtable/mod.rs index 5130665cd30..4b472d122f3 100644 --- a/vortex-array/src/arrays/primitive/vtable/mod.rs +++ b/vortex-array/src/arrays/primitive/vtable/mod.rs @@ -29,7 +29,7 @@ use vortex_buffer::Alignment; use vortex_session::VortexSession; use vortex_session::registry::CachedId; -use crate::Precision; +use crate::EqMode; use crate::array::ArrayId; use crate::arrays::primitive::array::SLOT_NAMES; use crate::arrays::primitive::compute::rules::RULES; @@ -40,14 +40,14 @@ use crate::hash::ArrayHash; pub type PrimitiveArray = Array; impl ArrayHash for PrimitiveData { - fn array_hash(&self, state: &mut H, precision: Precision) { - self.buffer.array_hash(state, precision); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.buffer.array_hash(state, accuracy); } } impl ArrayEq for PrimitiveData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - self.buffer.array_eq(&other.buffer, precision) + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { + self.buffer.array_eq(&other.buffer, accuracy) } } diff --git a/vortex-array/src/arrays/scalar_fn/array.rs b/vortex-array/src/arrays/scalar_fn/array.rs index 2c77be3c329..a999a25cd51 100644 --- a/vortex-array/src/arrays/scalar_fn/array.rs +++ b/vortex-array/src/arrays/scalar_fn/array.rs @@ -6,6 +6,7 @@ use std::fmt::Formatter; use vortex_error::VortexExpect; use vortex_error::VortexResult; +use vortex_error::vortex_bail; use vortex_error::vortex_ensure; use crate::ArrayRef; @@ -30,19 +31,6 @@ impl Display for ScalarFnData { } impl ScalarFnData { - /// Create a new ScalarFnArray from a scalar function and its children. - pub fn build( - scalar_fn: ScalarFnRef, - children: Vec, - len: usize, - ) -> VortexResult { - vortex_ensure!( - children.iter().all(|c| c.len() == len), - "ScalarFnArray must have children equal to the array length" - ); - Ok(Self { scalar_fn }) - } - /// Get the scalar function bound to this array. #[inline(always)] pub fn scalar_fn(&self) -> &ScalarFnRef { @@ -85,14 +73,26 @@ impl> ScalarFnArrayExt for T {} impl Array { /// Create a new ScalarFnArray from a scalar function and its children. - pub fn try_new( + pub fn try_new(scalar_fn: ScalarFnRef, children: Vec) -> VortexResult { + let len = Self::infer_len(&children)?; + Self::try_new_with_len(scalar_fn, children, len) + } + + /// Create a new ScalarFnArray from a scalar function, children, and an explicit length. + /// + /// This is needed for zero-child scalar functions and deserialization paths where there is no + /// child array to infer the length from. + pub fn try_new_with_len( scalar_fn: ScalarFnRef, children: Vec, len: usize, ) -> VortexResult { + Self::validate_children_len(&children, len)?; let arg_dtypes: Vec<_> = children.iter().map(|c| c.dtype().clone()).collect(); let dtype = scalar_fn.return_dtype(&arg_dtypes)?; - let data = ScalarFnData::build(scalar_fn.clone(), children.clone(), len)?; + let data = ScalarFnData { + scalar_fn: scalar_fn.clone(), + }; let vtable = ScalarFn { id: scalar_fn.id() }; Ok(unsafe { Array::from_parts_unchecked( @@ -101,4 +101,19 @@ impl Array { ) }) } + + fn infer_len(children: &[ArrayRef]) -> VortexResult { + let Some(child) = children.first() else { + vortex_bail!("ScalarFnArray length cannot be inferred without children"); + }; + Ok(child.len()) + } + + fn validate_children_len(children: &[ArrayRef], len: usize) -> VortexResult<()> { + vortex_ensure!( + children.iter().all(|c| c.len() == len), + "ScalarFnArray must have children equal to the array length" + ); + Ok(()) + } } diff --git a/vortex-array/src/arrays/scalar_fn/plugin.rs b/vortex-array/src/arrays/scalar_fn/plugin.rs index c685503a370..044f79362cd 100644 --- a/vortex-array/src/arrays/scalar_fn/plugin.rs +++ b/vortex-array/src/arrays/scalar_fn/plugin.rs @@ -82,7 +82,7 @@ impl ArrayPlugin for ScalarFnArrayPlugi let parts = ::deserialize( &self.0, dtype, len, metadata, children, session, )?; - Ok(ScalarFnArray::try_new( + Ok(ScalarFnArray::try_new_with_len( TypedScalarFnInstance::new(self.0.clone(), parts.options).erased(), parts.children, len, diff --git a/vortex-array/src/arrays/scalar_fn/rules.rs b/vortex-array/src/arrays/scalar_fn/rules.rs index 1e9563cf9de..abc8e18e762 100644 --- a/vortex-array/src/arrays/scalar_fn/rules.rs +++ b/vortex-array/src/arrays/scalar_fn/rules.rs @@ -84,7 +84,8 @@ impl ArrayParentReduceRule for ScalarFnSliceReduceRule { .collect::>()?; Ok(Some( - ScalarFnArray::try_new(array.scalar_fn().clone(), children, range.len())?.into_array(), + ScalarFnArray::try_new_with_len(array.scalar_fn().clone(), children, range.len())? + .into_array(), )) } } @@ -142,7 +143,7 @@ impl ReduceCtx for ArrayReduceCtx { children: &[ReduceNodeRef], ) -> VortexResult { Ok(Arc::new( - ScalarFnArray::try_new( + ScalarFnArray::try_new_with_len( scalar_fn, children .iter() @@ -191,8 +192,7 @@ impl ArrayParentReduceRule for ScalarFnUnaryFilterPushDownRule { .try_collect()?; let new_array = - ScalarFnArray::try_new(child.scalar_fn().clone(), new_children, parent.len())? - .into_array(); + ScalarFnArray::try_new(child.scalar_fn().clone(), new_children)?.into_array(); return Ok(Some(new_array)); } diff --git a/vortex-array/src/arrays/scalar_fn/vtable/mod.rs b/vortex-array/src/arrays/scalar_fn/vtable/mod.rs index 5906b3372f4..6833848deae 100644 --- a/vortex-array/src/arrays/scalar_fn/vtable/mod.rs +++ b/vortex-array/src/arrays/scalar_fn/vtable/mod.rs @@ -21,8 +21,8 @@ use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; use crate::ArraySlots; +use crate::EqMode; use crate::IntoArray; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayParts; @@ -56,13 +56,13 @@ pub struct ScalarFn { } impl ArrayHash for ScalarFnData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.scalar_fn().hash(state); } } impl ArrayEq for ScalarFnData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.scalar_fn() == other.scalar_fn() } } diff --git a/vortex-array/src/arrays/scalar_fn/vtable/operations.rs b/vortex-array/src/arrays/scalar_fn/vtable/operations.rs index 1f710af3cdf..67de19d936f 100644 --- a/vortex-array/src/arrays/scalar_fn/vtable/operations.rs +++ b/vortex-array/src/arrays/scalar_fn/vtable/operations.rs @@ -65,9 +65,12 @@ mod tests { use crate::arrays::BoolArray; use crate::arrays::PrimitiveArray; use crate::arrays::ScalarFnArray; + use crate::arrays::scalar_fn::ScalarFnArrayExt; use crate::assert_arrays_eq; + use crate::scalar::Scalar; use crate::scalar_fn::TypedScalarFnInstance; use crate::scalar_fn::fns::binary::Binary; + use crate::scalar_fn::fns::literal::Literal; use crate::scalar_fn::fns::operators::Operator; use crate::validity::Validity; @@ -77,7 +80,9 @@ mod tests { let rhs = buffer![10i32, 20, 30].into_array(); let scalar_fn = TypedScalarFnInstance::new(Binary, Operator::Add).erased(); - let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs], 3)?; + let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs])?; + + assert_eq!(scalar_fn_array.len(), 3); let result = scalar_fn_array .into_array() @@ -89,13 +94,47 @@ mod tests { Ok(()) } + #[test] + fn test_scalar_fn_inferred_len_rejects_mismatched_children() { + let lhs = buffer![1i32, 2, 3].into_array(); + let rhs = buffer![10i32, 20].into_array(); + + let scalar_fn = TypedScalarFnInstance::new(Binary, Operator::Add).erased(); + let err = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs]) + .expect_err("ScalarFnArray::try_new must reject mismatched child lengths"); + + assert!( + err.to_string() + .contains("ScalarFnArray must have children equal to the array length") + ); + } + + #[test] + fn test_scalar_fn_without_children_requires_explicit_len() -> VortexResult<()> { + let scalar_fn = TypedScalarFnInstance::new(Literal, Scalar::from(1i32)).erased(); + + let Err(err) = ScalarFnArray::try_new(scalar_fn.clone(), vec![]) else { + panic!("ScalarFnArray::try_new should reject zero children"); + }; + assert!( + err.to_string() + .contains("ScalarFnArray length cannot be inferred without children") + ); + + let scalar_fn_array = ScalarFnArray::try_new_with_len(scalar_fn, vec![], 3)?; + assert_eq!(scalar_fn_array.len(), 3); + assert_eq!(scalar_fn_array.child_count(), 0); + + Ok(()) + } + #[test] fn test_scalar_fn_mul() -> VortexResult<()> { let lhs = buffer![2i32, 3, 4].into_array(); let rhs = buffer![5i32, 6, 7].into_array(); let scalar_fn = TypedScalarFnInstance::new(Binary, Operator::Mul).erased(); - let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs], 3)?; + let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs])?; let result = scalar_fn_array .into_array() @@ -117,7 +156,7 @@ mod tests { .into_array(); let scalar_fn = TypedScalarFnInstance::new(Binary, Operator::Add).erased(); - let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs], 3)?; + let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs])?; let result = scalar_fn_array .into_array() @@ -139,7 +178,7 @@ mod tests { let rhs = buffer![2i32, 5, 1].into_array(); let scalar_fn = TypedScalarFnInstance::new(Binary, Operator::Eq).erased(); - let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs], 3)?; + let scalar_fn_array = ScalarFnArray::try_new(scalar_fn, vec![lhs, rhs])?; let result = scalar_fn_array .into_array() diff --git a/vortex-array/src/arrays/shared/tests.rs b/vortex-array/src/arrays/shared/tests.rs index 359a0919965..b8301389074 100644 --- a/vortex-array/src/arrays/shared/tests.rs +++ b/vortex-array/src/arrays/shared/tests.rs @@ -12,7 +12,7 @@ use crate::arrays::PrimitiveArray; use crate::arrays::SharedArray; use crate::arrays::shared::SharedArrayExt; use crate::hash::ArrayEq; -use crate::hash::Precision as HashPrecision; +use crate::hash::EqMode; use crate::session::ArraySession; use crate::validity::Validity; @@ -29,7 +29,7 @@ fn shared_array_caches_on_canonicalize() -> VortexResult<()> { // Second call should return cached without invoking the closure. let second = shared.get_or_compute(|_| panic!("should not execute twice"))?; - assert!(first.array_eq(&second, HashPrecision::Value)); + assert!(first.array_eq(&second, EqMode::Value)); Ok(()) } diff --git a/vortex-array/src/arrays/shared/vtable.rs b/vortex-array/src/arrays/shared/vtable.rs index f13a262479e..3c3a09216d2 100644 --- a/vortex-array/src/arrays/shared/vtable.rs +++ b/vortex-array/src/arrays/shared/vtable.rs @@ -13,9 +13,9 @@ use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; use crate::Canonical; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayView; @@ -39,11 +39,11 @@ pub type SharedArray = Array; pub struct Shared; impl ArrayHash for SharedData { - fn array_hash(&self, _state: &mut H, _precision: Precision) {} + fn array_hash(&self, _state: &mut H, _accuracy: EqMode) {} } impl ArrayEq for SharedData { - fn array_eq(&self, _other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, _other: &Self, _accuracy: EqMode) -> bool { true } } diff --git a/vortex-array/src/arrays/slice/vtable.rs b/vortex-array/src/arrays/slice/vtable.rs index ac0ecc18039..1ca91254123 100644 --- a/vortex-array/src/arrays/slice/vtable.rs +++ b/vortex-array/src/arrays/slice/vtable.rs @@ -19,7 +19,7 @@ use crate::AnyCanonical; use crate::ArrayEq; use crate::ArrayHash; use crate::ArrayRef; -use crate::Precision; +use crate::EqMode; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayView; @@ -47,14 +47,14 @@ pub type SliceArray = Array; pub struct Slice; impl ArrayHash for SliceData { - fn array_hash(&self, state: &mut H, _precision: Precision) { + fn array_hash(&self, state: &mut H, _accuracy: EqMode) { self.range.start.hash(state); self.range.end.hash(state); } } impl ArrayEq for SliceData { - fn array_eq(&self, other: &Self, _precision: Precision) -> bool { + fn array_eq(&self, other: &Self, _accuracy: EqMode) -> bool { self.range == other.range } } diff --git a/vortex-array/src/arrays/varbin/array.rs b/vortex-array/src/arrays/varbin/array.rs index deaef952d5e..4d435471ce0 100644 --- a/vortex-array/src/arrays/varbin/array.rs +++ b/vortex-array/src/arrays/varbin/array.rs @@ -6,6 +6,7 @@ use std::fmt::Formatter; use num_traits::AsPrimitive; use smallvec::smallvec; +use vortex_array::arrays::PrimitiveArray; use vortex_buffer::ByteBuffer; use vortex_error::VortexExpect; use vortex_error::VortexResult; @@ -15,8 +16,6 @@ use vortex_error::vortex_err; use crate::ArrayRef; use crate::ArraySlots; use crate::LEGACY_SESSION; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; @@ -208,26 +207,6 @@ impl VarBinData { InvalidArgument: "Offsets must have at least one element" ); - // Skip host-only validation when offsets/bytes are not host-resident. - if offsets.is_host() && bytes.is_on_host() { - let last_offset = offsets - .execute_scalar( - offsets.len() - 1, - &mut LEGACY_SESSION.create_execution_ctx(), - )? - .as_primitive() - .as_::() - .ok_or_else( - || vortex_err!(InvalidArgument: "Last offset must be convertible to usize"), - )?; - vortex_ensure!( - last_offset <= bytes.len(), - InvalidArgument: "Last offset {} exceeds bytes length {}", - last_offset, - bytes.len() - ); - } - // Check validity length if let Some(validity_len) = validity.maybe_len() { vortex_ensure!( @@ -244,33 +223,65 @@ impl VarBinData { && matches!(dtype, DType::Utf8(_)) && let Some(bytes) = bytes.as_host_opt() { - #[expect(deprecated)] - let primitive_offsets = offsets.to_primitive(); - match_each_integer_ptype!(primitive_offsets.dtype().as_ptype(), |O| { - let offsets_slice = primitive_offsets.as_slice::(); - for (i, (start, end)) in offsets_slice - .windows(2) - .map(|o| (o[0].as_(), o[1].as_())) - .enumerate() - { - if validity.is_null(i)? { - continue; - } - - let string_bytes = &bytes.as_ref()[start..end]; - simdutf8::basic::from_utf8(string_bytes).map_err(|_| { - #[expect(clippy::unwrap_used)] - // run validation using `compat` package to get more detailed error message - let err = simdutf8::compat::from_utf8(string_bytes).unwrap_err(); - vortex_err!("invalid utf-8: {err} at index {i}") - })?; - } - }); + Self::validate_utf8(offsets, bytes.as_ref(), validity)?; } Ok(()) } + /// Validates that every non-null value is valid UTF-8. + fn validate_utf8(offsets: &ArrayRef, bytes: &[u8], validity: &Validity) -> VortexResult<()> { + let validate_at = |i: usize, start: usize, end: usize| -> VortexResult<()> { + let string_bytes = &bytes[start..end]; + simdutf8::basic::from_utf8(string_bytes).map_err(|_| { + #[expect(clippy::unwrap_used)] + // run validation using `compat` package to get more detailed error message + let err = simdutf8::compat::from_utf8(string_bytes).unwrap_err(); + vortex_err!("invalid utf-8: {err} at index {i}") + })?; + Ok(()) + }; + + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + // TODO(joe): update the created VarBin with this decompressed Array. + let primitive_offsets = offsets.clone().execute::(&mut ctx)?; + + // Array-backed validity is the only variant that needs an execution context: execute it into + // a mask once. The constant variants resolve null-ness without one. Resolving this before + // the per-type dispatch keeps the dtype loop simple. + let mask = match validity { + Validity::Array(_) => { + Some(validity.execute_mask(primitive_offsets.len().saturating_sub(1), &mut ctx)?) + } + _ => None, + }; + let all_invalid = matches!(validity, Validity::AllInvalid); + + match_each_integer_ptype!(primitive_offsets.dtype().as_ptype(), |O| { + let offsets_slice = primitive_offsets.as_slice::(); + + let last_offset: usize = offsets_slice[offsets_slice.len() - 1].as_(); + vortex_ensure!( + last_offset <= bytes.len(), + InvalidArgument: "Last offset {} exceeds bytes length {}", + last_offset, + bytes.len() + ); + + for (i, (start, end)) in offsets_slice + .windows(2) + .map(|o| (o[0].as_(), o[1].as_())) + .enumerate() + { + let valid = mask.as_ref().map_or(!all_invalid, |mask| mask.value(i)); + if valid { + validate_at(i, start, end)?; + } + } + }); + Ok(()) + } + /// Access the value bytes child buffer /// /// # Note diff --git a/vortex-array/src/arrays/varbin/compute/compare.rs b/vortex-array/src/arrays/varbin/compute/compare.rs index 37ec64e0802..9a5a615c81e 100644 --- a/vortex-array/src/arrays/varbin/compute/compare.rs +++ b/vortex-array/src/arrays/varbin/compute/compare.rs @@ -23,7 +23,7 @@ use crate::arrays::VarBin; use crate::arrays::VarBinViewArray; use crate::arrays::varbin::VarBinArrayExt; use crate::arrow::Datum; -use crate::arrow::from_arrow_array_with_len; +use crate::arrow::from_arrow_columnar; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::IntegerPType; @@ -125,7 +125,7 @@ impl CompareKernel for VarBin { } .map_err(|err| vortex_err!("Failed to compare VarBin array: {}", err))?; - Ok(Some(from_arrow_array_with_len(&array, len, nullable)?)) + Ok(Some(from_arrow_columnar(&array, len, nullable, ctx)?)) } else if !rhs.is::() { // NOTE: If the rhs is not a VarBin array it will be canonicalized to a VarBinView // Arrow doesn't support comparing VarBin to VarBinView arrays, so we convert ourselves diff --git a/vortex-array/src/arrays/varbin/compute/filter.rs b/vortex-array/src/arrays/varbin/compute/filter.rs index b3e1aa87a4f..a0c78a64363 100644 --- a/vortex-array/src/arrays/varbin/compute/filter.rs +++ b/vortex-array/src/arrays/varbin/compute/filter.rs @@ -24,7 +24,6 @@ use crate::arrays::varbin::builder::VarBinBuilder; use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::match_each_integer_ptype; -use crate::validity::Validity; impl FilterKernel for VarBin { fn filter( @@ -170,7 +169,10 @@ fn filter_select_var_bin_by_index( offsets.as_slice::(), values.bytes().as_slice(), mask_indices, - values.validity()?, + values + .varbin_validity() + .execute_mask(values.as_ref().len(), ctx) + .vortex_expect("Failed to compute validity mask"), selection_count, ) }) @@ -181,24 +183,37 @@ fn filter_select_var_bin_by_index_primitive_offset( offsets: &[O], data: &[u8], mask_indices: &[usize], - // TODO(ngates): pass LogicalValidity instead - validity: Validity, + mask: Mask, selection_count: usize, ) -> VortexResult { + let value_at = |idx: usize| -> VortexResult<&[u8]> { + let start = offsets[idx] + .to_usize() + .ok_or_else(|| vortex_err!("Failed to convert offset to usize: {}", offsets[idx]))?; + let end = offsets[idx + 1].to_usize().ok_or_else(|| { + vortex_err!("Failed to convert offset to usize: {}", offsets[idx + 1]) + })?; + Ok(&data[start..end]) + }; + let mut builder = VarBinBuilder::::with_capacity(selection_count); - for idx in mask_indices.iter().copied() { - if validity.is_valid(idx)? { - let (start, end) = ( - offsets[idx].to_usize().ok_or_else(|| { - vortex_err!("Failed to convert offset to usize: {}", offsets[idx]) - })?, - offsets[idx + 1].to_usize().ok_or_else(|| { - vortex_err!("Failed to convert offset to usize: {}", offsets[idx + 1]) - })?, - ); - builder.append_value(&data[start..end]) - } else { - builder.append_null() + match mask.bit_buffer() { + AllOr::All => { + for idx in mask_indices.iter().copied() { + builder.append_value(value_at(idx)?) + } + } + AllOr::None => { + builder.append_n_nulls(selection_count); + } + AllOr::Some(validity) => { + for idx in mask_indices.iter().copied() { + if validity.value(idx) { + builder.append_value(value_at(idx)?) + } else { + builder.append_null() + } + } } } Ok(builder.finish(dtype)) diff --git a/vortex-array/src/arrays/varbin/compute/take.rs b/vortex-array/src/arrays/varbin/compute/take.rs index cb4b4031cd4..5826633aea3 100644 --- a/vortex-array/src/arrays/varbin/compute/take.rs +++ b/vortex-array/src/arrays/varbin/compute/take.rs @@ -202,8 +202,8 @@ fn take_nullable(&self, state: &mut H, precision: Precision) { - self.bytes().array_hash(state, precision); + fn array_hash(&self, state: &mut H, accuracy: EqMode) { + self.bytes().array_hash(state, accuracy); } } impl ArrayEq for VarBinData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { - self.bytes().array_eq(other.bytes(), precision) + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { + self.bytes().array_eq(other.bytes(), accuracy) } } diff --git a/vortex-array/src/arrays/varbinview/array.rs b/vortex-array/src/arrays/varbinview/array.rs index e302c9341d2..3ba90409939 100644 --- a/vortex-array/src/arrays/varbinview/array.rs +++ b/vortex-array/src/arrays/varbinview/array.rs @@ -18,6 +18,8 @@ use vortex_error::vortex_err; use vortex_error::vortex_panic; use crate::ArraySlots; +use crate::LEGACY_SESSION; +use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; use crate::array::TypedArrayRef; @@ -316,11 +318,7 @@ impl VarBinViewData { where F: Fn(&[u8]) -> bool, { - for (idx, &view) in views.iter().enumerate() { - if validity.is_null(idx)? { - continue; - } - + let validate_view = |idx: usize, view: &BinaryView| -> VortexResult<()> { if view.is_inlined() { // Validate the inline bytestring let bytes = &view.as_inlined().data[..view.len() as usize]; @@ -364,6 +362,30 @@ impl VarBinViewData { InvalidArgument: "view at index {idx}: outlined bytes fails utf-8 validation" ); } + Ok(()) + }; + + match validity { + // Array-backed validity is the only variant that needs an execution context: execute it + // into a mask once and zip it with the views, validating only the valid (non-null) + // entries. + Validity::Array(_) => { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mask = validity.execute_mask(views.len(), &mut ctx)?; + for ((idx, view), valid) in views.iter().enumerate().zip(mask.iter()) { + if valid { + validate_view(idx, view)?; + } + } + } + // Every entry is null, so there is nothing to validate. + Validity::AllInvalid => {} + // No nulls: validate every view. + Validity::NonNullable | Validity::AllValid => { + for (idx, view) in views.iter().enumerate() { + validate_view(idx, view)?; + } + } } Ok(()) diff --git a/vortex-array/src/arrays/varbinview/build_views.rs b/vortex-array/src/arrays/varbinview/build_views.rs index 87f5c84706b..4c35aab721a 100644 --- a/vortex-array/src/arrays/varbinview/build_views.rs +++ b/vortex-array/src/arrays/varbinview/build_views.rs @@ -24,15 +24,87 @@ pub fn offsets_to_lengths(offsets: &[P]) -> Buffer

{ /// Maximum number of buffer bytes that can be referenced by a single `BinaryView` pub const MAX_BUFFER_LEN: usize = i32::MAX as usize; -/// Split a large buffer of input `bytes` holding string data +/// Split a large buffer of input `bytes` holding string data into `VarBinView` buffers and views. +/// +/// `max_buffer_len` must not exceed [`MAX_BUFFER_LEN`], since every view offset is stored in a +/// `u32` and offsets are bounded by `max_buffer_len`. pub fn build_views>( start_buf_index: u32, max_buffer_len: usize, - mut bytes: ByteBufferMut, + bytes: ByteBufferMut, + lens: &[P], +) -> (Vec, Buffer) { + assert!( + max_buffer_len <= MAX_BUFFER_LEN, + "max_buffer_len cannot exceed MAX_BUFFER_LEN, offsets must fit in u32" + ); + + if bytes.len() <= max_buffer_len { + // Common case: the whole decoded heap fits within a single buffer, so no rollover can occur + // (`bytes.len()` is the total decoded size and therefore an upper bound on every offset). + build_views_single_buffer(start_buf_index, bytes, lens) + } else { + build_views_rolling(start_buf_index, max_buffer_len, bytes, lens) + } +} + +/// Build views when the whole heap fits in a single output buffer. +/// +/// Because no rollover can occur, the hot loop drops the per-element rollover branch and constructs +/// reference views inline, avoiding the out-of-line `BinaryView::make_view` call for the common +/// long-string case. Every offset is bounded by `bytes.len()`, which the caller has guaranteed is +/// at most [`MAX_BUFFER_LEN`], so the `usize -> u32` conversions cannot truncate. +fn build_views_single_buffer>( + start_buf_index: u32, + bytes: ByteBufferMut, lens: &[P], ) -> (Vec, Buffer) { let mut views = BufferMut::::with_capacity(lens.len()); + let data = bytes.as_slice(); + let mut offset = 0usize; + // Write directly into the reserved spare capacity rather than `push_unchecked`. The latter + // advances the backing buffer's length on every call, which the optimizer cannot prove is + // loop-invariant, so it reloads and rewrites the output cursor through the stack each + // iteration. Writing into the spare slice keeps the cursor in a register and the length is + // set once after the loop. + let spare = views.spare_capacity_mut(); + for (slot, &len) in spare.iter_mut().zip(lens) { + let len = len.as_(); + let value = &data[offset..offset + len]; + let view = if len > BinaryView::MAX_INLINED_SIZE { + let mut prefix = [0u8; 4]; + prefix.copy_from_slice(&value[..4]); + BinaryView::new_ref(len.as_(), prefix, start_buf_index, offset.as_()) + } else { + BinaryView::make_view(value, start_buf_index, offset.as_()) + }; + slot.write(view); + offset += len; + } + // SAFETY: the loop initialized exactly `lens.len()` contiguous views (`spare` has at least + // `lens.len()` slots, and `zip` stops at the shorter operand). + unsafe { views.set_len(lens.len()) }; + + let buffers = if bytes.is_empty() { + Vec::new() + } else { + vec![bytes.freeze()] + }; + (buffers, views.freeze()) +} + +/// Build views when the heap exceeds `max_buffer_len` and must be split across multiple buffers. +/// +/// The buffer is rolled over every `max_buffer_len` bytes so that no view offset overflows the +/// `u32` offset field. +fn build_views_rolling>( + start_buf_index: u32, + max_buffer_len: usize, + mut bytes: ByteBufferMut, + lens: &[P], +) -> (Vec, Buffer) { + let mut views = BufferMut::::with_capacity(lens.len()); let mut buffers = Vec::new(); let mut buf_index = start_buf_index; @@ -66,12 +138,275 @@ pub fn build_views>( #[cfg(test)] mod tests { + use rstest::rstest; use vortex_buffer::ByteBuffer; use vortex_buffer::ByteBufferMut; use crate::arrays::varbinview::BinaryView; + use crate::arrays::varbinview::build_views::MAX_BUFFER_LEN; use crate::arrays::varbinview::build_views::build_views; + /// Concatenate `values` into a single byte heap and return it alongside the per-element lengths, + /// matching the `(bytes, lens)` inputs that `build_views` consumes. + fn flatten(values: &[&[u8]]) -> (ByteBufferMut, Vec) { + let mut bytes = ByteBufferMut::empty(); + let mut lens = Vec::with_capacity(values.len()); + for v in values { + bytes.extend_from_slice(v); + lens.push(u32::try_from(v.len()).unwrap()); + } + (bytes, lens) + } + + /// Reconstruct the logical value behind each view by dereferencing it through the output + /// buffers. The first buffer corresponds to `start_buf_index`, so buffer indices are rebased by + /// that amount. This is the core correctness invariant: regardless of which code path built the + /// views, every view must point back at its original bytes. + fn reconstruct( + buffers: &[ByteBuffer], + views: &[BinaryView], + start_buf_index: u32, + ) -> Vec> { + views + .iter() + .map(|view| { + if view.is_inlined() { + view.as_inlined().value().to_vec() + } else { + let r = view.as_view(); + let buf = &buffers[(r.buffer_index - start_buf_index) as usize]; + buf[r.as_range()].to_vec() + } + }) + .collect() + } + + /// The single-buffer fast path (`bytes.len() <= max_buffer_len`) must reproduce every input + /// value exactly, emit a single output buffer holding the untouched heap, and reference only + /// `start_buf_index`. We cover a spread of value sets that mix inlined (<= 12 bytes) and + /// reference (> 12 bytes) lengths, including the 12/13 byte inline boundary, empty values, and a + /// fully-inlined set. + #[rstest] + #[case::mixed(&[b"a".as_slice(), b"this is a long reference value", b"short", b"another long value here!!"])] + #[case::inline_boundary(&[&[b'x'; 12] as &[u8], &[b'y'; 13], &[b'z'; 12], &[b'w'; 13]])] + #[case::all_inlined(&[b"".as_slice(), b"a", b"bb", b"ccc", b"dddddddddddd"])] + #[case::all_reference(&[&[b'a'; 100] as &[u8], &[b'b'; 50], &[b'c'; 4096]])] + #[case::empty_values_interleaved(&[b"".as_slice(), b"a long value that is referenced", b"", b"", b"trailing long reference value"])] + #[case::single_long(&[&[7u8; 1 << 16] as &[u8]])] + fn fast_path_roundtrip(#[case] values: &[&[u8]]) { + let (bytes, lens) = flatten(values); + let total = bytes.len(); + let start_buf_index = 3; + + // `max_buffer_len` strictly greater than the heap forces the single-buffer fast path. + let (buffers, views) = build_views(start_buf_index, total + 1, bytes, &lens); + + assert_eq!(views.len(), values.len()); + if total == 0 { + assert!(buffers.is_empty(), "empty heap must not allocate a buffer"); + } else { + assert_eq!(buffers.len(), 1, "whole heap must stay in one buffer"); + // The fast path freezes the input heap unchanged. + let concatenated: Vec = values.concat(); + assert_eq!(buffers[0].as_slice(), concatenated.as_slice()); + } + for view in views.iter() { + if !view.is_inlined() { + assert_eq!(view.as_view().buffer_index, start_buf_index); + } + } + + let expected: Vec> = values.iter().map(|v| v.to_vec()).collect(); + assert_eq!(reconstruct(&buffers, &views, start_buf_index), expected); + } + + /// Offsets and sizes are written into the `u32` `Ref` fields via `as_` truncation, so we must + /// confirm they stay correct once the running offset grows well past the 16-bit range (i.e. is + /// not narrowed to a smaller width). A ~9 MiB heap pushes offsets above 2^23 while remaining far + /// below `MAX_BUFFER_LEN`; each value encodes its index in its first bytes so a misplaced offset + /// would reconstruct the wrong bytes. + #[test] + fn fast_path_large_offsets() { + const N: usize = 9000; + const LEN: usize = 1000; + // The final offset is (N - 1) * LEN, which must exceed 2^23 to be a meaningful check. + const _: () = assert!((N - 1) * LEN > (1 << 23)); + + let values: Vec> = (0..N) + .map(|i| { + let mut v = vec![0u8; LEN]; + v[..4].copy_from_slice(&u32::try_from(i).unwrap().to_le_bytes()); + v + }) + .collect(); + let refs: Vec<&[u8]> = values.iter().map(|v| v.as_slice()).collect(); + + let (bytes, lens) = flatten(&refs); + let total = bytes.len(); + + let (buffers, views) = build_views(0, total + 1, bytes, &lens); + + assert_eq!(buffers.len(), 1); + // The recorded offset must equal the cumulative byte position, exactly, for every view. + for (i, view) in views.iter().enumerate() { + let r = view.as_view(); + assert_eq!(r.offset as usize, i * LEN, "wrong offset for view {i}"); + assert_eq!(r.size as usize, LEN); + } + assert_eq!(reconstruct(&buffers, &views, 0), values); + } + + /// The fast path is taken when `bytes.len() <= max_buffer_len`, so equality at the boundary must + /// still produce a single buffer (not roll over to the slow path). + #[test] + fn fast_path_taken_at_exact_boundary() { + let (bytes, lens) = + flatten(&[b"this value is definitely long", b"and so is this one here"]); + let total = bytes.len(); + + let (buffers, views) = build_views(0, total, bytes, &lens); + + assert_eq!( + buffers.len(), + 1, + "len == max_buffer_len must stay on fast path" + ); + assert_eq!(views.len(), 2); + } + + /// For the same logical data, the fast path (single buffer) and the slow rollover path must + /// reconstruct identical values. Driving the slow path with a small `max_buffer_len` forces + /// buffer splitting while leaving the recovered values unchanged. + #[test] + fn fast_and_slow_paths_agree() { + let values: &[&[u8]] = &[ + b"first long reference value", + b"tiny", + b"second long reference value!!", + b"third looooong reference value", + ]; + let expected: Vec> = values.iter().map(|v| v.to_vec()).collect(); + + let (fast_bytes, lens) = flatten(values); + let total = fast_bytes.len(); + let (fast_buffers, fast_views) = build_views(0, total + 1, fast_bytes, &lens); + assert_eq!(fast_buffers.len(), 1); + assert_eq!(reconstruct(&fast_buffers, &fast_views, 0), expected); + + // Force the rollover path: a small cap (>= the longest value) that the total heap exceeds. + let longest = values.iter().map(|v| v.len()).max().unwrap(); + let (slow_bytes, _) = flatten(values); + let (slow_buffers, slow_views) = build_views(0, longest, slow_bytes, &lens); + assert!( + slow_buffers.len() > 1, + "small cap should split into many buffers" + ); + assert_eq!(reconstruct(&slow_buffers, &slow_views, 0), expected); + + // Same logical contents regardless of how the heap was partitioned. + assert_eq!( + reconstruct(&fast_buffers, &fast_views, 0), + reconstruct(&slow_buffers, &slow_views, 0) + ); + } + + /// Empty input must yield no buffers and no views, exercising the `bytes.is_empty()` branch. + #[test] + fn fast_path_empty_input() { + let lens: Vec = Vec::new(); + let (buffers, views) = build_views(0, 1024, ByteBufferMut::empty(), &lens); + assert!(buffers.is_empty()); + assert!(views.is_empty()); + } + + /// The fast path must produce views byte-identical to the value-inspecting `make_view`, which is + /// what the slow path uses. This pins the inline/reference decision and field layout. + #[test] + fn fast_path_matches_make_view() { + let values: &[&[u8]] = &[b"inline", b"this is a long reference value", b""]; + let (bytes, lens) = flatten(values); + let total = bytes.len(); + let (_buffers, views) = build_views(0, total + 1, bytes, &lens); + + let expected = [ + BinaryView::make_view(b"inline", 0, 0), + BinaryView::make_view(b"this is a long reference value", 0, 6), + BinaryView::make_view(b"", 0, 36), + ]; + assert_eq!(views.as_slice(), &expected); + } + + // TODO(someone): ideally CI would run this in release mode as well, since debug builds make the + // ~2.25 GiB allocation and fill loop substantially slower. + /// Slow regression for the single-buffer fast-path guard. The fast path is only valid when the + /// whole heap fits in one buffer (`bytes.len() <= max_buffer_len`); once the heap exceeds + /// [`MAX_BUFFER_LEN`] (`i32::MAX`, ~2.0 GiB) `build_views` must roll the heap into multiple + /// buffers, resetting the per-buffer offset, so no view references an offset past the + /// `i32`-bounded buffer limit. + /// + /// We build a heap just past `i32::MAX` and assert it rolls over into more than one buffer, that + /// no buffer exceeds `MAX_BUFFER_LEN`, and that values straddling the rollover boundary (where + /// the second buffer's offsets restart from zero) reconstruct exactly. If the guard regressed and + /// the fast path swallowed the whole heap, it would emit a single >2 GiB buffer with offsets past + /// `i32::MAX`, which the buffer-count and buffer-size assertions catch. + /// + /// Allocates ~2.25 GiB, so it is gated to CI and skipped when `VORTEX_SKIP_SLOW_TESTS` is set: + /// + /// ```text + /// CI=1 cargo test --release -p vortex-array build_views_offsets_overflow + /// ``` + /// + /// [`MAX_BUFFER_LEN`]: super::MAX_BUFFER_LEN + #[test_with::env(CI)] + #[test_with::no_env(VORTEX_SKIP_SLOW_TESTS)] + fn build_views_offsets_overflow_i32() { + const STRING_LEN: usize = 64 * 1024; + // Comfortably past MAX_BUFFER_LEN (`i32::MAX` ~= 2.0 GiB) so the heap must roll over. + const TOTAL_BYTES: usize = (1usize << 31) + (256 << 20); // ~2.25 GiB + const N: usize = TOTAL_BYTES / STRING_LEN; + + // Each value's first 8 bytes encode its row index, so a misrouted offset is detectable. + let nth_string = |i: usize| { + let mut s = vec![b'x'; STRING_LEN]; + s[..8].copy_from_slice(&(i as u64).to_le_bytes()); + s + }; + + let mut bytes = ByteBufferMut::with_capacity(N * STRING_LEN); + let mut value = vec![b'x'; STRING_LEN]; + for i in 0..N { + value[..8].copy_from_slice(&(i as u64).to_le_bytes()); + bytes.extend_from_slice(&value); + } + + let lens = vec![u32::try_from(STRING_LEN).unwrap(); N]; + let (buffers, views) = build_views(0, MAX_BUFFER_LEN, bytes, &lens); + + assert_eq!(views.len(), N); + assert!( + buffers.len() >= 2, + "heap exceeding MAX_BUFFER_LEN must roll over into multiple buffers, got {}", + buffers.len() + ); + for (i, b) in buffers.iter().enumerate() { + assert!( + b.len() <= MAX_BUFFER_LEN, + "buffer {i} of {} bytes exceeds MAX_BUFFER_LEN", + b.len() + ); + } + + // The boundary row is the first whose offset would cross MAX_BUFFER_LEN on the fast path. + let boundary = MAX_BUFFER_LEN / STRING_LEN; + for i in [0, boundary - 1, boundary, boundary + 1, N / 2, N - 1] { + let view = &views[i]; + let r = view.as_view(); + let got = &buffers[r.buffer_index as usize][r.as_range()]; + assert_eq!(got, nth_string(i).as_slice(), "value mismatch at row {i}"); + assert_eq!(r.size as usize, STRING_LEN); + } + } + #[test] fn test_to_canonical_large() { // We are testing generating views for raw data that should look like @@ -107,4 +442,15 @@ mod tests { ] ) } + + #[test] + #[should_panic(expected = "max_buffer_len cannot exceed MAX_BUFFER_LEN")] + fn test_max_buffer_len_too_large_panics() { + build_views( + 0, + MAX_BUFFER_LEN + 1, + ByteBufferMut::copy_from("abc"), + &[3u32], + ); + } } diff --git a/vortex-array/src/arrays/varbinview/compute/slice.rs b/vortex-array/src/arrays/varbinview/compute/slice.rs index 19f5a0d41b0..e53da7a6eb3 100644 --- a/vortex-array/src/arrays/varbinview/compute/slice.rs +++ b/vortex-array/src/arrays/varbinview/compute/slice.rs @@ -16,16 +16,19 @@ use crate::arrays::varbinview::BinaryView; impl SliceReduce for VarBinView { fn slice(array: ArrayView<'_, Self>, range: Range) -> VortexResult> { - Ok(Some( - VarBinViewArray::new_handle( - array - .views_handle() - .slice_typed::(range.clone()), - Arc::clone(array.data_buffers()), - array.dtype().clone(), - array.validity()?.slice(range)?, - ) - .into_array(), - )) + let views = array + .views_handle() + .slice_typed::(range.clone()); + let data_buffers = Arc::clone(array.data_buffers()); + let dtype = array.dtype().clone(); + let validity = array.validity()?.slice(range)?; + + // Safety: + // range is validated within bounds, and is shared between all children. + let array = unsafe { + VarBinViewArray::new_handle_unchecked(views, data_buffers, dtype, validity).into_array() + }; + + Ok(Some(array)) } } diff --git a/vortex-array/src/arrays/varbinview/compute/zip.rs b/vortex-array/src/arrays/varbinview/compute/zip.rs index f4a5c26a9ce..9c198fe6f2c 100644 --- a/vortex-array/src/arrays/varbinview/compute/zip.rs +++ b/vortex-array/src/arrays/varbinview/compute/zip.rs @@ -177,6 +177,7 @@ fn push_range( } } +#[inline] fn push_view( view: BinaryView, buffer_lookup: &[u32], diff --git a/vortex-array/src/arrays/varbinview/view.rs b/vortex-array/src/arrays/varbinview/view.rs index 38cbd2798c6..1b07baa4576 100644 --- a/vortex-array/src/arrays/varbinview/view.rs +++ b/vortex-array/src/arrays/varbinview/view.rs @@ -150,17 +150,15 @@ impl BinaryView { 12 => Self { inlined: Inlined::new::<12>(value), }, - _ => Self { - _ref: Ref { - size: u32::try_from(value.len()).vortex_expect("value length must fit in u32"), - prefix: value[0..4] - .try_into() - .ok() - .vortex_expect("prefix must be exactly 4 bytes"), - buffer_index: block, - offset, - }, - }, + _ => Self::new_ref( + u32::try_from(value.len()).vortex_expect("value length must fit in u32"), + value[0..4] + .try_into() + .ok() + .vortex_expect("prefix must be exactly 4 bytes"), + block, + offset, + ), } } @@ -170,6 +168,27 @@ impl BinaryView { Self { le_bytes: [0; 16] } } + /// Create a reference view directly from its components, without inspecting the value. + /// + /// `size` must be greater than [`MAX_INLINED_SIZE`], and `prefix` must hold the first four + /// bytes of the value. This is the fast path for bulk view construction where the caller has + /// already established that the value is too long to inline; it assembles the 16-byte view as a + /// single `u128` so the compiler can emit one wide store per view. + /// + /// [`MAX_INLINED_SIZE`]: Self::MAX_INLINED_SIZE + #[inline] + pub fn new_ref(size: u32, prefix: [u8; 4], buffer_index: u32, offset: u32) -> Self { + debug_assert!(size as usize > Self::MAX_INLINED_SIZE); + // Matches the little-endian field order of `Ref` (size, prefix, buffer_index, offset), + // consistent with `le_bytes` and the `From`/`as_u128` representation. + Self::from( + u128::from(size) + | (u128::from(u32::from_le_bytes(prefix)) << 32) + | (u128::from(buffer_index) << 64) + | (u128::from(offset) << 96), + ) + } + /// Create a new inlined binary view /// /// # Panics @@ -278,3 +297,36 @@ impl fmt::Debug for BinaryView { s.finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + // Just past the inline boundary, typical, and large values. + #[case(13, 7, 42)] + #[case(20, 7, 42)] + #[case(255, 7, 42)] + #[case(4096, 7, 42)] + // Zero buffer index/offset and the `u32` extremes, to confirm the `u128` field assembly does + // not overflow into neighbouring fields. + #[case(13, 0, 0)] + #[case(13, u32::MAX, u32::MAX)] + fn new_ref_matches_make_view(#[case] len: u32, #[case] buffer_index: u32, #[case] offset: u32) { + // `new_ref` assembles the reference view as a `u128`; it must be byte-identical to the + // value-inspecting `make_view` for any value longer than the inline limit. + let value: Vec = (0..len) + .map(|i| u8::try_from(i % 251).vortex_expect("i % 251 fits in u8")) + .collect(); + let prefix = [value[0], value[1], value[2], value[3]]; + let made = BinaryView::make_view(&value, buffer_index, offset); + let built = BinaryView::new_ref(len, prefix, buffer_index, offset); + assert_eq!(made.as_u128(), built.as_u128(), "mismatch at len {len}"); + assert!(!built.is_inlined()); + let r = built.as_view(); + assert_eq!(r.size, len); + assert_eq!(r.prefix, prefix); + assert_eq!(r.buffer_index, buffer_index); + assert_eq!(r.offset, offset); + } +} diff --git a/vortex-array/src/arrays/varbinview/vtable/mod.rs b/vortex-array/src/arrays/varbinview/vtable/mod.rs index 45db5d904ac..c093b9c0203 100644 --- a/vortex-array/src/arrays/varbinview/vtable/mod.rs +++ b/vortex-array/src/arrays/varbinview/vtable/mod.rs @@ -16,9 +16,9 @@ use vortex_session::VortexSession; use vortex_session::registry::CachedId; use crate::ArrayRef; +use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; -use crate::Precision; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayView; @@ -44,23 +44,23 @@ pub type VarBinViewArray = Array; pub struct VarBinView; impl ArrayHash for VarBinViewData { - fn array_hash(&self, state: &mut H, precision: Precision) { + fn array_hash(&self, state: &mut H, accuracy: EqMode) { for buffer in self.buffers.iter() { - buffer.array_hash(state, precision); + buffer.array_hash(state, accuracy); } - self.views.array_hash(state, precision); + self.views.array_hash(state, accuracy); } } impl ArrayEq for VarBinViewData { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { self.buffers.len() == other.buffers.len() && self .buffers .iter() .zip(other.buffers.iter()) - .all(|(a, b)| a.array_eq(b, precision)) - && self.views.array_eq(&other.views, precision) + .all(|(a, b)| a.array_eq(b, accuracy)) + && self.views.array_eq(&other.views, accuracy) } } diff --git a/vortex-array/src/arrow/convert.rs b/vortex-array/src/arrow/convert.rs index 419caacd82d..3d536402e5c 100644 --- a/vortex-array/src/arrow/convert.rs +++ b/vortex-array/src/arrow/convert.rs @@ -58,14 +58,15 @@ use arrow_buffer::buffer::NullBuffer; use arrow_buffer::buffer::OffsetBuffer; use arrow_schema::DataType; use arrow_schema::TimeUnit as ArrowTimeUnit; -use itertools::Itertools; use vortex_buffer::Alignment; use vortex_buffer::BitBuffer; use vortex_buffer::Buffer; use vortex_buffer::ByteBuffer; -use vortex_error::VortexExpect as _; use vortex_error::VortexResult; use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; +use vortex_error::vortex_ensure_eq; +use vortex_error::vortex_err; use vortex_error::vortex_panic; use crate::ArrayRef; @@ -141,7 +142,7 @@ macro_rules! impl_from_arrow_primitive { impl FromArrowArray<&ArrowPrimitiveArray<$T>> for ArrayRef { fn from_arrow(value: &ArrowPrimitiveArray<$T>, nullable: bool) -> VortexResult { let buffer = Buffer::from_arrow_scalar_buffer(value.values().clone()); - let validity = nulls(value.nulls(), nullable); + let validity = nulls(value.nulls(), nullable)?; Ok(PrimitiveArray::new(buffer, validity).into_array()) } } @@ -167,7 +168,7 @@ impl FromArrowArray<&ArrowPrimitiveArray> for ArrayRef { ) -> VortexResult { let decimal_type = DecimalDType::new(array.precision(), array.scale()); let buffer = Buffer::from_arrow_scalar_buffer(array.values().clone()); - let validity = nulls(array.nulls(), nullable); + let validity = nulls(array.nulls(), nullable)?; Ok(DecimalArray::new(buffer, decimal_type, validity).into_array()) } } @@ -179,7 +180,7 @@ impl FromArrowArray<&ArrowPrimitiveArray> for ArrayRef { ) -> VortexResult { let decimal_type = DecimalDType::new(array.precision(), array.scale()); let buffer = Buffer::from_arrow_scalar_buffer(array.values().clone()); - let validity = nulls(array.nulls(), nullable); + let validity = nulls(array.nulls(), nullable)?; Ok(DecimalArray::new(buffer, decimal_type, validity).into_array()) } } @@ -191,7 +192,7 @@ impl FromArrowArray<&ArrowPrimitiveArray> for ArrayRef { ) -> VortexResult { let decimal_type = DecimalDType::new(array.precision(), array.scale()); let buffer = Buffer::from_arrow_scalar_buffer(array.values().clone()); - let validity = nulls(array.nulls(), nullable); + let validity = nulls(array.nulls(), nullable)?; Ok(DecimalArray::new(buffer, decimal_type, validity).into_array()) } } @@ -208,7 +209,7 @@ impl FromArrowArray<&ArrowPrimitiveArray> for ArrayRef { // of either type. let buffer = unsafe { std::mem::transmute::, Buffer>(buffer) }; - let validity = nulls(array.nulls(), nullable); + let validity = nulls(array.nulls(), nullable)?; Ok(DecimalArray::new(buffer, decimal_type, validity).into_array()) } } @@ -220,7 +221,7 @@ macro_rules! impl_from_arrow_temporal { value: &ArrowPrimitiveArray<$T>, nullable: bool, ) -> vortex_error::VortexResult { - Ok(temporal_array(value, nullable)) + temporal_array(value, nullable) } } }; @@ -242,17 +243,20 @@ impl_from_arrow_temporal!(Time64NanosecondType); impl_from_arrow_temporal!(Date32Type); impl_from_arrow_temporal!(Date64Type); -fn temporal_array(value: &ArrowPrimitiveArray, nullable: bool) -> ArrayRef +fn temporal_array( + value: &ArrowPrimitiveArray, + nullable: bool, +) -> VortexResult where T::Native: NativePType, { let arr = PrimitiveArray::new( Buffer::from_arrow_scalar_buffer(value.values().clone()), - nulls(value.nulls(), nullable), + nulls(value.nulls(), nullable)?, ) .into_array(); - match value.data_type() { + Ok(match value.data_type() { DataType::Timestamp(time_unit, tz) => { TemporalArray::new_timestamp(arr, time_unit.into(), tz.clone()).into() } @@ -263,7 +267,7 @@ where DataType::Duration(_) => unimplemented!(), DataType::Interval(_) => unimplemented!(), _ => vortex_panic!("Invalid temporal type: {}", value.data_type()), - } + }) } impl FromArrowArray<&GenericByteArray> for ArrayRef @@ -282,7 +286,7 @@ where value.offsets().clone().into_array(), ByteBuffer::from_arrow_buffer(value.values().clone(), Alignment::of::()), dtype, - nulls(value.nulls(), nullable), + nulls(value.nulls(), nullable)?, ) } .into_array()) @@ -314,7 +318,7 @@ impl FromArrowArray<&GenericByteViewArray> for ArrayRef { .collect::>(), ), dtype, - nulls(value.nulls(), nullable), + nulls(value.nulls(), nullable)?, ) .into_array() }) @@ -325,17 +329,17 @@ impl FromArrowArray<&ArrowBooleanArray> for ArrayRef { fn from_arrow(value: &ArrowBooleanArray, nullable: bool) -> VortexResult { Ok(BoolArray::new( value.values().clone().into(), - nulls(value.nulls(), nullable), + nulls(value.nulls(), nullable)?, ) .into_array()) } } /// Strip out the nulls from this array and return a new array without nulls. -pub(crate) fn remove_nulls(data: arrow_data::ArrayData) -> arrow_data::ArrayData { +pub(crate) fn remove_nulls(data: arrow_data::ArrayData) -> VortexResult { if data.null_count() == 0 { // No nulls to remove, return the array as is - return data; + return Ok(data); } let children = match data.data_type() { @@ -345,12 +349,12 @@ pub(crate) fn remove_nulls(data: arrow_data::ArrayData) -> arrow_data::ArrayData .zip(data.child_data().iter()) .map(|(field, child_data)| { if field.is_nullable() { - child_data.clone() + Ok(child_data.clone()) } else { remove_nulls(child_data.clone()) } }) - .collect_vec(), + .collect::>>()?, ), DataType::List(f) | DataType::LargeList(f) @@ -360,12 +364,12 @@ pub(crate) fn remove_nulls(data: arrow_data::ArrayData) -> arrow_data::ArrayData if !f.is_nullable() => { // All list types only have one child - assert_eq!( + vortex_ensure_eq!( data.child_data().len(), 1, "List types should have one child" ); - Some(vec![remove_nulls(data.child_data()[0].clone())]) + Some(vec![remove_nulls(data.child_data()[0].clone())?]) } _ => None, }; @@ -376,7 +380,7 @@ pub(crate) fn remove_nulls(data: arrow_data::ArrayData) -> arrow_data::ArrayData } builder .build() - .vortex_expect("reconstructing array without nulls") + .map_err(|e| vortex_err!("Failed to reconstruct Arrow array without nulls: {e}")) } impl FromArrowArray<&ArrowStructArray> for ArrayRef { @@ -391,7 +395,7 @@ impl FromArrowArray<&ArrowStructArray> for ArrayRef { // Arrow pushes down nulls, even into non-nullable fields. So we strip them // out here because Vortex is a little more strict. if c.null_count() > 0 && !field.is_nullable() { - let stripped = make_array(remove_nulls(c.into_data())); + let stripped = make_array(remove_nulls(c.into_data())?); Self::from_arrow(stripped.as_ref(), false) } else { Self::from_arrow(c.as_ref(), field.is_nullable()) @@ -399,7 +403,7 @@ impl FromArrowArray<&ArrowStructArray> for ArrayRef { }) .collect::>>()?, value.len(), - nulls(value.nulls(), nullable), + nulls(value.nulls(), nullable)?, )? .into_array()) } @@ -418,7 +422,7 @@ impl FromArrowArray<&GenericListArray> for // `offsets` are always non-nullable. let offsets = value.offsets().clone().into_array(); - let nulls = nulls(value.nulls(), nullable); + let nulls = nulls(value.nulls(), nullable)?; Ok(ListArray::try_new(elements, offsets, nulls)?.into_array()) } @@ -438,7 +442,7 @@ impl FromArrowArray<&GenericListViewArray> // `offsets` and `sizes` are always non-nullable. let offsets = array.offsets().clone().into_array(); let sizes = array.sizes().clone().into_array(); - let nulls = nulls(array.nulls(), nullable); + let nulls = nulls(array.nulls(), nullable)?; Ok(ListViewArray::try_new(elements, offsets, sizes, nulls)?.into_array()) } @@ -453,7 +457,7 @@ impl FromArrowArray<&ArrowFixedSizeListArray> for ArrayRef { Ok(FixedSizeListArray::try_new( Self::from_arrow(array.values().as_ref(), field.is_nullable())?, *list_size as u32, - nulls(array.nulls(), nullable), + nulls(array.nulls(), nullable)?, array.len(), )? .into_array()) @@ -466,7 +470,7 @@ impl FromArrowArray<&ArrowMapArray> for ArrayRef { // We convert it to a ListArray of structs. let entries = Self::from_arrow(value.entries() as &dyn ArrowArray, false)?; let offsets = value.offsets().clone().into_array(); - let nulls = nulls(value.nulls(), nullable); + let nulls = nulls(value.nulls(), nullable)?; Ok(ListArray::try_new(entries, offsets, nulls)?.into_array()) } @@ -474,7 +478,10 @@ impl FromArrowArray<&ArrowMapArray> for ArrayRef { impl FromArrowArray<&ArrowNullArray> for ArrayRef { fn from_arrow(value: &ArrowNullArray, nullable: bool) -> VortexResult { - assert!(nullable); + vortex_ensure!( + nullable, + "Cannot convert an Arrow NullArray into a non-nullable Vortex array" + ); Ok(NullArray::new(value.len()).into_array()) } } @@ -489,9 +496,9 @@ impl FromArrowArray<&DictionaryArray> for DictArra } } -pub(crate) fn nulls(nulls: Option<&NullBuffer>, nullable: bool) -> Validity { +pub(crate) fn nulls(nulls: Option<&NullBuffer>, nullable: bool) -> VortexResult { if nullable { - nulls + Ok(nulls .map(|nulls| { if nulls.null_count() == nulls.len() { Validity::AllInvalid @@ -499,10 +506,15 @@ pub(crate) fn nulls(nulls: Option<&NullBuffer>, nullable: bool) -> Validity { Validity::from(BitBuffer::from(nulls.inner().clone())) } }) - .unwrap_or_else(|| Validity::AllValid) + .unwrap_or(Validity::AllValid)) } else { - assert!(nulls.map(|x| x.null_count() == 0).unwrap_or(true)); - Validity::NonNullable + let null_count = nulls.map(NullBuffer::null_count).unwrap_or(0); + vortex_ensure_eq!( + null_count, + 0, + "Cannot convert an Arrow array containing {null_count} nulls into a non-nullable Vortex array" + ); + Ok(Validity::NonNullable) } } @@ -1504,8 +1516,48 @@ mod tests { } #[test] - #[should_panic] - pub fn cannot_handle_nullable_struct_containing_non_nullable_dictionary() { + fn non_nullable_request_rejects_nulls() { + // Requesting `nullable = false` on an Arrow array that physically contains nulls is a + // contradiction and must surface as an error, not a panic. + let arrow_array = Int32Array::from(vec![Some(1), None, Some(3)]); + assert!(ArrayRef::from_arrow(&arrow_array, false).is_err()); + } + + #[test] + fn non_nullable_request_rejects_null_array() { + // An Arrow NullArray is entirely null, so it cannot be converted to a non-nullable + // Vortex array. + let arrow_array = NullArray::new(5); + assert!(ArrayRef::from_arrow(&arrow_array, false).is_err()); + } + + #[test] + fn non_nullable_struct_with_nulls_errors() { + // A struct array carrying top-level nulls cannot be converted to a non-nullable Vortex + // struct; the struct-level validity reconciliation must error rather than panic. + let struct_array = new_null_array( + &DataType::Struct(Fields::from(vec![Field::new("a", DataType::Int32, true)])), + 3, + ); + assert!(ArrayRef::from_arrow(struct_array.as_ref(), false).is_err()); + } + + #[test] + fn non_nullable_list_with_nulls_errors() { + // Likewise for a list array with a null entry: requesting a non-nullable list must error + // rather than panic. + let mut builder = ListBuilder::new(Int32Builder::new()); + builder.append_value([Some(1), Some(2)]); + builder.append_null(); + let list = builder.finish(); + assert!(ArrayRef::from_arrow(&list, false).is_err()); + } + + #[test] + pub fn nullable_struct_containing_non_nullable_dictionary_with_nulls_errors() { + // `remove_nulls` cannot strip pushed-down nulls out of a non-nullable dictionary field, + // so the values end up converted with `nullable = false` while still containing nulls. + // This must surface as an error rather than panicking. let null_struct_array_with_non_nullable_field = new_null_array( &DataType::Struct(Fields::from(vec![Field::new( "non_nullable_deeper_inner", @@ -1515,7 +1567,9 @@ mod tests { 1, ); - ArrayRef::from_arrow(null_struct_array_with_non_nullable_field.as_ref(), true).unwrap(); + assert!( + ArrayRef::from_arrow(null_struct_array_with_non_nullable_field.as_ref(), true).is_err() + ); } #[test] diff --git a/vortex-array/src/arrow/datum.rs b/vortex-array/src/arrow/datum.rs index 5cea4e3b1a1..b2f047b5948 100644 --- a/vortex-array/src/arrow/datum.rs +++ b/vortex-array/src/arrow/datum.rs @@ -105,6 +105,9 @@ impl ArrowDatum for Datum { /// # Error /// /// The provided array must have length +#[deprecated( + note = "Relies on the hidden global `LEGACY_SESSION`; use `from_arrow_columnar` with an explicit `ExecutionCtx` instead" +)] pub fn from_arrow_array_with_len(array: A, len: usize, nullable: bool) -> VortexResult where ArrayRef: FromArrowArray, @@ -131,3 +134,38 @@ where ) .into_array()) } + +/// Convert an Arrow array to an Array with a specific length, using the provided +/// [`ExecutionCtx`]. +/// +/// This is useful for compute functions that delegate to Arrow using [Datum], +/// which will return a scalar (length 1 Arrow array) if the input array is constant. +/// +/// # Error +/// +/// The provided array must have length `len` or `1`. +pub fn from_arrow_columnar( + array: A, + len: usize, + nullable: bool, + ctx: &mut ExecutionCtx, +) -> VortexResult +where + ArrayRef: FromArrowArray, +{ + let array = ArrayRef::from_arrow(array, nullable)?; + if array.len() == len { + return Ok(array); + } + + if array.len() != 1 { + vortex_panic!( + "Array length mismatch, expected {} got {} for encoding {}", + len, + array.len(), + array.encoding_id() + ); + } + + Ok(ConstantArray::new(array.execute_scalar(0, ctx)?, len).into_array()) +} diff --git a/vortex-array/src/arrow/executor/list.rs b/vortex-array/src/arrow/executor/list.rs index d59088ade34..e8bf95e14cd 100644 --- a/vortex-array/src/arrow/executor/list.rs +++ b/vortex-array/src/arrow/executor/list.rs @@ -33,6 +33,7 @@ use crate::dtype::DType; use crate::dtype::NativePType; use crate::dtype::Nullability; +#[allow(rustdoc::broken_intra_doc_links)] /// Convert a Vortex VarBinArray into an Arrow [`GenericListArray`](arrow_array:array::GenericListArray). pub(super) fn to_arrow_list( array: ArrayRef, @@ -61,7 +62,7 @@ pub(super) fn to_arrow_list( let zctl = if array.is_zero_copy_to_list() { array } else { - array.rebuild(ListViewRebuildMode::MakeZeroCopyToList)? + array.rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx)? }; return list_view_zctl::(zctl, elements_field, ctx); } @@ -76,11 +77,12 @@ pub(super) fn to_arrow_list( let zctl = if list_view.is_zero_copy_to_list() { list_view } else { - list_view.rebuild(ListViewRebuildMode::MakeZeroCopyToList)? + list_view.rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx)? }; list_view_zctl::(zctl, elements_field, ctx) } +#[allow(rustdoc::broken_intra_doc_links)] /// Convert a Vortex VarBinArray into an Arrow [`GenericListArray`](arrow_array:array::GenericListArray). fn list_to_list( array: &ListArray, @@ -201,6 +203,7 @@ fn list_view_zctl( #[cfg(test)] mod tests { use std::sync::Arc; + use std::sync::LazyLock; use arrow_array::Array; use arrow_array::GenericListArray; @@ -209,21 +212,26 @@ mod tests { use arrow_schema::Field; use vortex_buffer::buffer; use vortex_error::VortexResult; + use vortex_session::VortexSession; use crate::Canonical; use crate::IntoArray; - use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::PrimitiveArray; use crate::arrow::ArrowArrayExecutor; use crate::arrow::executor::list::ListViewArray; use crate::dtype::DType; use crate::dtype::Nullability::NonNullable; + use crate::session::ArraySession; use crate::validity::Validity; + /// A shared session for these list-executor tests, used to create execution contexts. + static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[test] fn test_to_arrow_list_i32() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Create a ListViewArray with i32 elements: [[1, 2, 3], [4, 5]] let elements = PrimitiveArray::new(buffer![1i32, 2, 3, 4, 5], Validity::NonNullable); let offsets = PrimitiveArray::new(buffer![0i32, 3], Validity::NonNullable); @@ -278,7 +286,7 @@ mod tests { #[test] fn test_to_arrow_list_i64() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Create a ListViewArray with i64 offsets: [[10, 20], [30]] let elements = PrimitiveArray::new(buffer![10i64, 20, 30], Validity::NonNullable); let offsets = PrimitiveArray::new(buffer![0i64, 2], Validity::NonNullable); @@ -318,7 +326,7 @@ mod tests { #[test] fn test_to_arrow_list_non_zctl() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Overlapping lists are NOT zero-copy-to-list, so this exercises the rebuild path. // Elements: [1, 2, 3, 4], List 0: [1,2,3], List 1: [2,3,4] (overlap at indices 1-2) let elements = PrimitiveArray::new(buffer![1i32, 2, 3, 4], Validity::NonNullable); @@ -360,7 +368,7 @@ mod tests { #[test] fn test_to_arrow_list_empty_zctl() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let dtype = DType::List( Arc::new(DType::Primitive(crate::dtype::PType::I32, NonNullable)), NonNullable, diff --git a/vortex-array/src/arrow/executor/list_view.rs b/vortex-array/src/arrow/executor/list_view.rs index b77d079f509..5a2cd517b37 100644 --- a/vortex-array/src/arrow/executor/list_view.rs +++ b/vortex-array/src/arrow/executor/list_view.rs @@ -54,7 +54,7 @@ pub(super) fn to_arrow_list_view( } } else if array.upper_bound_density(ctx)? < DEFAULT_REBUILD_DENSITY_THRESHOLD { // Overlaps, gaps, or garbage may be present, so a full rebuild is needed to reclaim waste. - array.rebuild(ListViewRebuildMode::MakeZeroCopyToList)? + array.rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx)? } else { array }; @@ -109,24 +109,31 @@ fn list_view_to_list_view( #[cfg(test)] mod tests { + use std::sync::LazyLock; + use arrow_array::Array; use arrow_array::GenericListViewArray; use arrow_schema::DataType; use arrow_schema::Field; use vortex_buffer::buffer; use vortex_error::VortexResult; + use vortex_session::VortexSession; use crate::IntoArray; - use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrow::ArrowArrayExecutor; use crate::arrow::executor::list_view::ListViewArray; use crate::arrow::executor::list_view::PrimitiveArray; + use crate::session::ArraySession; use crate::validity::Validity; + /// A shared session for these list-view-executor tests, used to create execution contexts. + static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); + #[test] fn trims_zero_copy_with_significant_trailing_waste() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Zero-copy-to-list array with 10 elements but only [0, 4) referenced -> 60% waste. // The conversion should trim the elements buffer down to the referenced range. let elements = PrimitiveArray::new( @@ -161,7 +168,7 @@ mod tests { #[test] fn test_to_arrow_listview_i32() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Create a ListViewArray with overlapping views: [[1, 2], [2, 3], [3, 4]] let elements = PrimitiveArray::new(buffer![1i32, 2, 3, 4], Validity::NonNullable); let offsets = PrimitiveArray::new(buffer![0i32, 1, 2], Validity::NonNullable); @@ -216,7 +223,7 @@ mod tests { #[test] fn test_to_arrow_listview_i64() -> VortexResult<()> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Create a ListViewArray with nullable elements: [[100], null, [200, 300]] let elements = PrimitiveArray::new(buffer![100i64, 200, 300], Validity::NonNullable); let offsets = PrimitiveArray::new(buffer![0i64, 1, 1], Validity::NonNullable); diff --git a/vortex-array/src/arrow/mod.rs b/vortex-array/src/arrow/mod.rs index 81e5f635bcb..5259005dffc 100644 --- a/vortex-array/src/arrow/mod.rs +++ b/vortex-array/src/arrow/mod.rs @@ -26,7 +26,28 @@ use crate::ArrayRef; use crate::LEGACY_SESSION; use crate::VortexSessionExecute; +/// Construct a Vortex array from an Arrow array (or other Arrow container) of type `A`. +/// +/// Implementations reuse the underlying Arrow buffers without copying wherever the Arrow and +/// Vortex memory layouts allow it. pub trait FromArrowArray { + /// Convert `array` into a Vortex array whose [`DType`](crate::dtype::DType) has the requested + /// `nullable` [`Nullability`](crate::dtype::Nullability). + /// + /// An Arrow array can carry a validity (null) buffer regardless of whether its schema declares + /// the field nullable, so the desired nullability is supplied separately by the caller + /// (typically from the corresponding Arrow `Field`'s `is_nullable`). This flag is reconciled + /// with the array's physical nulls as follows: + /// + /// - `nullable == true`: the resulting validity is derived from the array's null buffer, or + /// all-valid when the array has none. + /// - `nullable == false`: the array must contain no nulls, and the result is non-nullable. + /// + /// # Errors + /// + /// Returns an error if `nullable` is `false` but `array` physically contains one or more nulls + /// (including an Arrow `NullArray`, which is entirely null), or if the Arrow data type is not + /// supported. fn from_arrow(array: A, nullable: bool) -> VortexResult where Self: Sized; diff --git a/vortex-array/src/arrow/session.rs b/vortex-array/src/arrow/session.rs index 9aa83cbbd2e..705f7971f90 100644 --- a/vortex-array/src/arrow/session.rs +++ b/vortex-array/src/arrow/session.rs @@ -24,7 +24,6 @@ use std::any::Any; use std::fmt::Debug; use std::sync::Arc; -use arc_swap::ArcSwap; use arrow_array::Array as _; use arrow_array::ArrayRef as ArrowArrayRef; use arrow_array::RecordBatch; @@ -44,11 +43,11 @@ use vortex_session::Ref; use vortex_session::SessionExt; use vortex_session::SessionVar; use vortex_session::registry::Id; -use vortex_utils::aliases::hash_map::HashMap; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; +use crate::arc_swap_map::ArcSwapMap; use crate::arrays::StructArray; use crate::arrow::FromArrowArray; use crate::arrow::convert::nulls; @@ -61,6 +60,7 @@ use crate::dtype::Nullability; use crate::dtype::StructFields; use crate::dtype::arrow::FromArrowType; use crate::dtype::arrow::to_data_type_naive; +use crate::dtype::extension::ExtId; use crate::extension::datetime::AnyTemporal; use crate::extension::uuid::Uuid; use crate::validity::Validity; @@ -154,10 +154,6 @@ pub trait ArrowImportVTable: 'static + Send + Sync + Debug { pub type ArrowExportVTableRef = Arc; pub type ArrowImportVTableRef = Arc; -type ExportMap = HashMap>; -type ImportMap = HashMap>; -type ExportDTypeMap = HashMap>; - /// Session-scoped registry of Arrow extension plugins. /// /// Exporters are stored in two indices: one keyed by Arrow extension Id (used for @@ -169,17 +165,17 @@ type ExportDTypeMap = HashMap>; /// need plugins. #[derive(Debug)] pub struct ArrowSession { - exporters: ArcSwap, - exporters_by_vortex: ArcSwap, - importers: ArcSwap, + exporters: ArcSwapMap>, + exporters_by_vortex: ArcSwapMap>, + importers: ArcSwapMap>, } impl Default for ArrowSession { fn default() -> Self { let session = Self { - exporters: ArcSwap::from_pointee(ExportMap::default()), - exporters_by_vortex: ArcSwap::from_pointee(ExportDTypeMap::default()), - importers: ArcSwap::from_pointee(ImportMap::default()), + exporters: ArcSwapMap::default(), + exporters_by_vortex: ArcSwapMap::default(), + importers: ArcSwapMap::default(), }; session.register_exporter(Arc::new(Uuid)); @@ -193,56 +189,31 @@ impl ArrowSession { /// Register an [`ArrowExportVTable`] under its target Arrow extension Id (for dispatch) /// and its source Vortex extension Id (for schema inference). pub fn register_exporter(&self, exporter: ArrowExportVTableRef) { - Self::insert( - &self.exporters, + self.exporters.push( exporter.arrow_ext_id(), ArrowExportVTableRef::clone(&exporter), ); - Self::insert(&self.exporters_by_vortex, exporter.vortex_id(), exporter); + self.exporters_by_vortex + .push(exporter.vortex_id(), exporter); } /// Register an [`ArrowImportVTable`] under its source Arrow extension name. pub fn register_importer(&self, importer: ArrowImportVTableRef) { - Self::insert(&self.importers, importer.arrow_ext_id(), importer); - } - - fn insert(slot: &ArcSwap>>, key: K, value: T) - where - K: Clone + Eq + std::hash::Hash, - T: Clone, - { - slot.rcu(move |map| { - let mut next = (**map).clone(); - let entry = next.entry(key.clone()).or_insert_with(|| Arc::from([])); - let mut extended: Vec = entry.iter().cloned().collect(); - extended.push(value.clone()); - *entry = Arc::from(extended); - next - }); + self.importers.push(importer.arrow_ext_id(), importer); } fn exporters(&self, id: &Id) -> Arc<[ArrowExportVTableRef]> { - self.exporters - .load() - .get(id) - .cloned() - .unwrap_or_else(|| Arc::from([])) + self.exporters.get(id).unwrap_or_else(|| Arc::from([])) } fn exporters_by_vortex(&self, id: &Id) -> Arc<[ArrowExportVTableRef]> { self.exporters_by_vortex - .load() .get(id) - .cloned() .unwrap_or_else(|| Arc::from([])) } fn importers(&self, id: &Id) -> Arc<[ArrowImportVTableRef]> { - self.importers - .load() - .get(id) - .cloned() - .unwrap_or_else(|| Arc::from([])) + self.importers.get(id).unwrap_or_else(|| Arc::from([])) } /// Build the Arrow [`Field`] for a Vortex [`DType`]. @@ -544,14 +515,14 @@ impl ArrowSession { // Arrow pushes nulls into non-nullable fields; strip before recursing // so Vortex's stricter validity invariants are upheld. let inner = if col.null_count() > 0 && !child_field.is_nullable() { - make_array(remove_nulls(col.to_data())) + make_array(remove_nulls(col.to_data())?) } else { ArrowArrayRef::clone(col) }; self.from_arrow_array(inner, child_field.as_ref()) }) .collect::>>()?; - let validity = nulls(arrow_struct.nulls(), field.is_nullable()); + let validity = nulls(arrow_struct.nulls(), field.is_nullable())?; Ok( StructArray::try_new(names, columns, arrow_struct.len(), validity)? .into_array(), @@ -562,7 +533,7 @@ impl ArrowSession { let elements = self .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?; let offsets = list.offsets().clone().into_array(); - let validity = nulls(list.nulls(), field.is_nullable()); + let validity = nulls(list.nulls(), field.is_nullable())?; Ok(crate::arrays::ListArray::try_new(elements, offsets, validity)?.into_array()) } DataType::LargeList(elem_field) => { @@ -570,14 +541,14 @@ impl ArrowSession { let elements = self .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?; let offsets = list.offsets().clone().into_array(); - let validity = nulls(list.nulls(), field.is_nullable()); + let validity = nulls(list.nulls(), field.is_nullable())?; Ok(crate::arrays::ListArray::try_new(elements, offsets, validity)?.into_array()) } DataType::FixedSizeList(elem_field, list_size) => { let fsl = array.as_fixed_size_list(); let elements = self.from_arrow_array(ArrowArrayRef::clone(fsl.values()), elem_field.as_ref())?; - let validity = nulls(fsl.nulls(), field.is_nullable()); + let validity = nulls(fsl.nulls(), field.is_nullable())?; Ok(crate::arrays::FixedSizeListArray::try_new( elements, *list_size as u32, @@ -592,7 +563,7 @@ impl ArrowSession { .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?; let offsets = list.offsets().clone().into_array(); let sizes = list.sizes().clone().into_array(); - let validity = nulls(list.nulls(), field.is_nullable()); + let validity = nulls(list.nulls(), field.is_nullable())?; Ok( crate::arrays::ListViewArray::try_new(elements, offsets, sizes, validity)? .into_array(), @@ -604,7 +575,7 @@ impl ArrowSession { .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?; let offsets = list.offsets().clone().into_array(); let sizes = list.sizes().clone().into_array(); - let validity = nulls(list.nulls(), field.is_nullable()); + let validity = nulls(list.nulls(), field.is_nullable())?; Ok( crate::arrays::ListViewArray::try_new(elements, offsets, sizes, validity)? .into_array(), diff --git a/vortex-array/src/buffer.rs b/vortex-array/src/buffer.rs index 29f9d3582be..6c59140c9a6 100644 --- a/vortex-array/src/buffer.rs +++ b/vortex-array/src/buffer.rs @@ -19,7 +19,7 @@ use vortex_utils::dyn_traits::DynHash; use crate::ArrayEq; use crate::ArrayHash; -use crate::Precision; +use crate::EqMode; /// A handle to a buffer allocation. /// @@ -249,6 +249,7 @@ impl BufferHandle { } /// Downcast this handle as a handle to a host-resident buffer, or `None`. + #[inline] pub fn as_host_opt(&self) -> Option<&ByteBuffer> { match &self.0 { Inner::Host(buffer) => Some(buffer), @@ -266,6 +267,7 @@ impl BufferHandle { /// A version of [`as_host_opt`][Self::as_host_opt] that panics if the allocation is /// not a host allocation. + #[inline] pub fn as_host(&self) -> &ByteBuffer { self.as_host_opt().vortex_expect("expected host buffer") } @@ -407,14 +409,14 @@ impl BufferHandle { impl ArrayHash for BufferHandle { // TODO(aduffy): implement for array hash - fn array_hash(&self, state: &mut H, precision: Precision) { + fn array_hash(&self, state: &mut H, accuracy: EqMode) { match &self.0 { - Inner::Host(host) => host.array_hash(state, precision), - Inner::Device(dev) => match precision { - Precision::Ptr => { + Inner::Host(host) => host.array_hash(state, accuracy), + Inner::Device(dev) => match accuracy { + EqMode::Ptr => { Arc::as_ptr(dev).hash(state); } - Precision::Value => { + EqMode::Value => { dev.hash(state); } }, @@ -423,12 +425,12 @@ impl ArrayHash for BufferHandle { } impl ArrayEq for BufferHandle { - fn array_eq(&self, other: &Self, precision: Precision) -> bool { + fn array_eq(&self, other: &Self, accuracy: EqMode) -> bool { match (&self.0, &other.0) { - (Inner::Host(b), Inner::Host(b2)) => b.array_eq(b2, precision), - (Inner::Device(b), Inner::Device(b2)) => match precision { - Precision::Ptr => Arc::ptr_eq(b, b2), - Precision::Value => b.eq(b2), + (Inner::Host(b), Inner::Host(b2)) => b.array_eq(b2, accuracy), + (Inner::Device(b), Inner::Device(b2)) => match accuracy { + EqMode::Ptr => Arc::ptr_eq(b, b2), + EqMode::Value => b.eq(b2), }, _ => false, } diff --git a/vortex-array/src/builders/bool.rs b/vortex-array/src/builders/bool.rs index c09d69a5acd..e829a58f6ec 100644 --- a/vortex-array/src/builders/bool.rs +++ b/vortex-array/src/builders/bool.rs @@ -121,7 +121,7 @@ impl ArrayBuilder for BoolBuilder { self.inner.append_buffer(&bool_array.to_bit_buffer()); self.nulls.append_validity_mask( - bool_array + &bool_array .as_ref() .validity() .vortex_expect("validity_mask") @@ -139,8 +139,7 @@ impl ArrayBuilder for BoolBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { @@ -210,11 +209,11 @@ mod tests { #[expect(deprecated)] let into_canon = chunk.to_bool(); - assert!( - canon_into - .validity()? - .mask_eq(&into_canon.validity()?, &mut ctx)? - ); + assert!(canon_into.validity()?.mask_eq( + &into_canon.validity()?, + canon_into.len(), + &mut ctx + )?); assert_eq!(canon_into.to_bit_buffer(), into_canon.to_bit_buffer()); Ok(()) } diff --git a/vortex-array/src/builders/decimal.rs b/vortex-array/src/builders/decimal.rs index c80f97d9ea2..f8d438d477b 100644 --- a/vortex-array/src/builders/decimal.rs +++ b/vortex-array/src/builders/decimal.rs @@ -207,7 +207,7 @@ impl ArrayBuilder for DecimalBuilder { }); self.nulls.append_validity_mask( - decimal_array + &decimal_array .as_ref() .validity() .vortex_expect("validity_mask") @@ -225,8 +225,7 @@ impl ArrayBuilder for DecimalBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { diff --git a/vortex-array/src/builders/extension.rs b/vortex-array/src/builders/extension.rs index d947877e297..7e1da3b8f3e 100644 --- a/vortex-array/src/builders/extension.rs +++ b/vortex-array/src/builders/extension.rs @@ -54,6 +54,8 @@ impl ExtensionBuilder { } /// The [`ExtDType`] of this builder. + /// + /// [`ExtDType`]: crate::dtype::extension::ExtDType fn ext_dtype(&self) -> ExtDTypeRef { if let DType::Extension(ext_dtype) = &self.dtype { ext_dtype.clone() diff --git a/vortex-array/src/builders/fixed_size_list.rs b/vortex-array/src/builders/fixed_size_list.rs index 5937c37b041..7a3c3bb068a 100644 --- a/vortex-array/src/builders/fixed_size_list.rs +++ b/vortex-array/src/builders/fixed_size_list.rs @@ -246,7 +246,7 @@ impl ArrayBuilder for FixedSizeListBuilder { self.elements_builder.extend_from_array(fsl.elements()); self.nulls.append_validity_mask( - array + &array .validity() .vortex_expect("validity_mask in extend_from_array_unchecked") .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) @@ -261,8 +261,7 @@ impl ArrayBuilder for FixedSizeListBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { @@ -444,6 +443,7 @@ mod tests { #[test] fn test_nullable_lists_non_nullable_elements() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(DType::Primitive(I32, NonNullable)); let mut builder = FixedSizeListBuilder::with_capacity(Arc::clone(&dtype), 2, Nullable, 0); @@ -475,21 +475,21 @@ mod tests { fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); assert!( !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); } @@ -562,6 +562,7 @@ mod tests { #[test] fn test_append_nulls() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); // Elements must be nullable if we're going to append null lists let dtype: Arc = Arc::new(DType::Primitive(I32, Nullable)); let mut builder = FixedSizeListBuilder::with_capacity(dtype, 2, Nullable, 0); @@ -583,7 +584,7 @@ mod tests { !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(i) + .execute_is_valid(i, &mut ctx) .unwrap() ); } @@ -591,6 +592,7 @@ mod tests { #[test] fn test_append_scalar_nulls() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); // Elements must be nullable if we're going to append null lists let dtype: Arc = Arc::new(DType::Primitive(I32, Nullable)); let mut builder = FixedSizeListBuilder::with_capacity(dtype, 2, Nullable, 0); @@ -613,7 +615,7 @@ mod tests { !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); } @@ -663,6 +665,7 @@ mod tests { #[test] fn test_extend_from_array() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); // Create a source array. @@ -691,48 +694,49 @@ mod tests { fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); assert!( !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(3) + .execute_is_valid(3, &mut ctx) .unwrap() ); assert!( !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(4) + .execute_is_valid(4, &mut ctx) .unwrap() ); assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(5) + .execute_is_valid(5, &mut ctx) .unwrap() ); } #[test] fn test_extend_degenerate_arrays() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); // Create degenerate source arrays (size = 0). @@ -768,35 +772,35 @@ mod tests { fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); assert!( !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); assert!( !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(3) + .execute_is_valid(3, &mut ctx) .unwrap() ); assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(4) + .execute_is_valid(4, &mut ctx) .unwrap() ); } @@ -837,6 +841,7 @@ mod tests { #[test] fn test_mixed_operations() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); // Use nullable elements since we'll be appending nulls let dtype: Arc = Arc::new(DType::Primitive(I32, Nullable)); let mut builder = FixedSizeListBuilder::with_capacity(Arc::clone(&dtype), 2, Nullable, 0); @@ -880,48 +885,49 @@ mod tests { fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); // append_value assert!( !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); // append_null assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); // append_zeros assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(3) + .execute_is_valid(3, &mut ctx) .unwrap() ); // append_zeros assert!( !fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(4) + .execute_is_valid(4, &mut ctx) .unwrap() ); // append_nulls assert!( fsl_array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(5) + .execute_is_valid(5, &mut ctx) .unwrap() ); // extend_from_array } #[test] fn test_append_scalar() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); let mut builder = FixedSizeListBuilder::with_capacity(Arc::clone(&dtype), 2, Nullable, 10); @@ -943,9 +949,7 @@ mod tests { // Check actual values using scalar_at. - let scalar0 = array - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let scalar0 = array.execute_scalar(0, &mut ctx).unwrap(); let list0 = scalar0.as_list(); assert_eq!(list0.len(), 2); if let Some(list0_items) = list0.elements() { @@ -953,9 +957,7 @@ mod tests { assert_eq!(list0_items[1].as_primitive().typed_value::(), Some(2)); } - let scalar1 = array - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let scalar1 = array.execute_scalar(1, &mut ctx).unwrap(); let list1 = scalar1.as_list(); assert_eq!(list1.len(), 2); if let Some(list1_items) = list1.elements() { @@ -968,21 +970,21 @@ mod tests { array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); assert!( array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); assert!( !array .validity() .vortex_expect("fixed-size-list validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); diff --git a/vortex-array/src/builders/lazy_null_builder.rs b/vortex-array/src/builders/lazy_null_builder.rs index 9ca4b28b484..24abe9ba17d 100644 --- a/vortex-array/src/builders/lazy_null_builder.rs +++ b/vortex-array/src/builders/lazy_null_builder.rs @@ -31,6 +31,42 @@ impl LazyBitBufferBuilder { } } + /// Creates a builder pre-populated from a validity mask, taking ownership of the mask's buffer + /// instead of copying it where possible. + /// + /// This is the counterpart to [`append_validity_mask`](Self::append_validity_mask) for callers + /// that want to *replace* the builder's contents with the mask rather than extend them: because + /// we own the mask, we can move its buffer in instead of copying it. + pub fn from_validity_mask(validity_mask: Mask) -> Self { + match validity_mask { + // An unmaterialized builder already represents `len` non-null values, so an all-valid + // mask stays lazy. + Mask::AllTrue(len) => Self { + inner: None, + len, + capacity: len, + }, + Mask::AllFalse(len) => Self::from_buffer(BitBufferMut::new_unset(len)), + // Take ownership of the underlying buffer; `into_bit_buffer` and `try_into_mut` only + // copy when the buffer is shared, otherwise this is a move. + values @ Mask::Values(_) => Self::from_buffer( + values + .into_bit_buffer() + .try_into_mut() + .unwrap_or_else(|buffer| BitBufferMut::copy_from(&buffer)), + ), + } + } + + /// Creates a builder backed by an already-materialized buffer. + fn from_buffer(inner: BitBufferMut) -> Self { + Self { + inner: Some(inner), + len: 0, + capacity: 0, + } + } + /// Appends `n` non-null values to the builder. #[inline] pub fn append_n_non_nulls(&mut self, n: usize) { @@ -82,10 +118,13 @@ impl LazyBitBufferBuilder { } /// Appends values from a validity mask. - pub fn append_validity_mask(&mut self, validity_mask: Mask) { + /// + /// Takes the mask by reference: the `Mask::Values` case copies the underlying buffer into the + /// running null buffer, so there is nothing to gain from owning the mask. + pub fn append_validity_mask(&mut self, validity_mask: &Mask) { match validity_mask { - Mask::AllTrue(len) => self.append_n_non_nulls(len), - Mask::AllFalse(len) => self.append_n_nulls(len), + Mask::AllTrue(len) => self.append_n_non_nulls(*len), + Mask::AllFalse(len) => self.append_n_nulls(*len), Mask::Values(is_valid) => self.append_buffer(is_valid.bit_buffer()), } } diff --git a/vortex-array/src/builders/list.rs b/vortex-array/src/builders/list.rs index ae1bb20dfa9..ac97230daa8 100644 --- a/vortex-array/src/builders/list.rs +++ b/vortex-array/src/builders/list.rs @@ -226,7 +226,7 @@ impl ArrayBuilder for ListBuilder { // Append validity information. self.nulls.append_validity_mask( - array + &array .validity() .vortex_expect("validity_mask in extend_from_array_unchecked") .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) @@ -300,8 +300,7 @@ impl ArrayBuilder for ListBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { @@ -491,6 +490,7 @@ mod tests { &expected .validity() .vortex_expect("list validity should be derivable"), + actual.len(), &mut ctx, ) .unwrap(), @@ -580,11 +580,11 @@ mod tests { let array = builder.finish_into_list(); assert_eq!(array.len(), 3); + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + // Check actual values using scalar_at. - let scalar0 = array - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let scalar0 = array.execute_scalar(0, &mut ctx).unwrap(); let list0 = scalar0.as_list(); assert_eq!(list0.len(), 2); if let Some(list0_items) = list0.elements() { @@ -592,9 +592,7 @@ mod tests { assert_eq!(list0_items[1].as_primitive().typed_value::(), Some(2)); } - let scalar1 = array - .execute_scalar(1, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let scalar1 = array.execute_scalar(1, &mut ctx).unwrap(); let list1 = scalar1.as_list(); assert_eq!(list1.len(), 3); if let Some(list1_items) = list1.elements() { @@ -603,9 +601,7 @@ mod tests { assert_eq!(list1_items[2].as_primitive().typed_value::(), Some(5)); } - let scalar2 = array - .execute_scalar(2, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap(); + let scalar2 = array.execute_scalar(2, &mut ctx).unwrap(); let list2 = scalar2.as_list(); assert!(list2.is_null()); // This should be null. @@ -614,21 +610,21 @@ mod tests { array .validity() .vortex_expect("list validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); assert!( array .validity() .vortex_expect("list validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); assert!( !array .validity() .vortex_expect("list validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); diff --git a/vortex-array/src/builders/listview.rs b/vortex-array/src/builders/listview.rs index 3c7bb8e8885..4c350f1888d 100644 --- a/vortex-array/src/builders/listview.rs +++ b/vortex-array/src/builders/listview.rs @@ -21,8 +21,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::Canonical; use crate::LEGACY_SESSION; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array::IntoArray; use crate::arrays::ListViewArray; @@ -294,8 +292,16 @@ impl ArrayBuilder for ListViewBuilder { } unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let listview = array.to_listview(); + // TODO: The `ArrayBuilder` trait does not thread an `ExecutionCtx` through its extend + // methods, so we are forced to mint a fresh `LEGACY_SESSION` context here on every call + // (which for chunked input means once per chunk). Once the trait carries a `&mut + // ExecutionCtx`, the caller's session should be reused instead. + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + + let listview = array + .clone() + .execute::(&mut ctx) + .vortex_expect("failed to execute array into ListViewArray in extend_from_array"); if listview.is_empty() { return; } @@ -303,15 +309,15 @@ impl ArrayBuilder for ListViewBuilder { // Normalize to an exact zero-copy-to-list layout and then bulk append. This avoids the // very expensive scalar_at-per-list path for overlapping / out-of-order list views. let listview = listview - .rebuild(ListViewRebuildMode::MakeExact) + .rebuild(ListViewRebuildMode::MakeExact, &mut ctx) .vortex_expect("ListViewArray::rebuild(MakeExact) failed in extend_from_array"); debug_assert!(listview.is_zero_copy_to_list()); self.nulls.append_validity_mask( - array + &array .validity() .vortex_expect("validity_mask in extend_from_array_unchecked") - .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) + .execute_mask(array.len(), &mut ctx) .vortex_expect("Failed to compute validity mask"), ); @@ -343,8 +349,11 @@ impl ArrayBuilder for ListViewBuilder { let uninit_range = self.offsets_builder.uninit_range(extend_length); // This should be cheap because we didn't compress after rebuilding. - #[expect(deprecated)] - let new_offsets = listview.offsets().to_primitive(); + let new_offsets = listview + .offsets() + .clone() + .execute::(&mut ctx) + .vortex_expect("failed to execute list view offsets into a PrimitiveArray"); match_each_integer_ptype!(new_offsets.ptype(), |A| { adjust_and_extend_offsets::( @@ -364,8 +373,7 @@ impl ArrayBuilder for ListViewBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { @@ -426,6 +434,8 @@ mod tests { use super::ListViewBuilder; use crate::IntoArray; + use crate::LEGACY_SESSION; + use crate::VortexSessionExecute; use crate::arrays::ListArray; use crate::arrays::ListViewArray; use crate::arrays::listview::ListViewArrayExt; @@ -450,6 +460,7 @@ mod tests { #[test] fn test_basic_append_and_nulls() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); let mut builder = ListViewBuilder::::with_capacity(Arc::clone(&dtype), Nullable, 0, 0); @@ -498,7 +509,7 @@ mod tests { !listview .validity() .vortex_expect("listview validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); @@ -580,6 +591,7 @@ mod tests { #[test] fn test_builder_trait_methods() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); let mut builder = ListViewBuilder::::with_capacity(Arc::clone(&dtype), Nullable, 0, 0); @@ -611,14 +623,14 @@ mod tests { !listview .validity() .vortex_expect("listview validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); assert!( !listview .validity() .vortex_expect("listview validity should be derivable") - .is_valid(3) + .execute_is_valid(3, &mut ctx) .unwrap() ); @@ -631,6 +643,7 @@ mod tests { #[test] fn test_extend_from_array() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); // Create a source ListArray. @@ -680,7 +693,7 @@ mod tests { !listview .validity() .vortex_expect("listview validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); @@ -693,6 +706,7 @@ mod tests { #[test] fn test_extend_from_array_overlapping_listview() { + let mut ctx = LEGACY_SESSION.create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); // Non-ZCTL source: @@ -725,7 +739,7 @@ mod tests { !listview .validity() .vortex_expect("listview validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); assert_eq!(listview.list_elements_at(1).unwrap().len(), 0); diff --git a/vortex-array/src/builders/primitive.rs b/vortex-array/src/builders/primitive.rs index 59381fc602f..c4b5abfb083 100644 --- a/vortex-array/src/builders/primitive.rs +++ b/vortex-array/src/builders/primitive.rs @@ -127,7 +127,7 @@ impl PrimitiveBuilder { } /// Extends the primitive array with an iterator. - pub fn extend_with_iterator(&mut self, iter: impl IntoIterator, mask: Mask) { + pub fn extend_with_iterator(&mut self, iter: impl IntoIterator, mask: &Mask) { self.values.extend(iter); self.nulls.append_validity_mask(mask); } @@ -190,7 +190,7 @@ impl ArrayBuilder for PrimitiveBuilder { self.values.extend_from_slice(array.as_slice::()); self.nulls.append_validity_mask( - array + &array .as_ref() .validity() .vortex_expect("validity_mask") @@ -208,8 +208,7 @@ impl ArrayBuilder for PrimitiveBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { @@ -271,7 +270,7 @@ impl UninitRange<'_, T> { /// - The caller must ensure that they safely initialize `mask.len()` primitive values via /// [`UninitRange::copy_from_slice`]. /// - The caller must also ensure that they only call this method once. - pub unsafe fn append_mask(&mut self, mask: Mask) { + pub unsafe fn append_mask(&mut self, mask: &Mask) { assert_eq!( mask.len(), self.len, @@ -426,7 +425,7 @@ mod tests { // SAFETY: We're about to initialize the values. unsafe { - range.append_mask(mask); + range.append_mask(&mask); } // Initialize the values. @@ -476,7 +475,7 @@ mod tests { // SAFETY: This is expected to panic due to length mismatch. unsafe { - range.append_mask(wrong_mask); + range.append_mask(&wrong_mask); } } @@ -522,7 +521,7 @@ mod tests { let initial_mask = Mask::from_iter([false, false, false]); // SAFETY: We're about to initialize the values. unsafe { - range.append_mask(initial_mask); + range.append_mask(&initial_mask); } // Now we can use set_bit to modify individual bits with relative indexing. @@ -623,7 +622,7 @@ mod tests { let mask = Mask::from_iter([true, true, false]); // SAFETY: We're about to initialize the matching number of values. unsafe { - range.append_mask(mask); + range.append_mask(&mask); } // Initialize all values. @@ -671,25 +670,26 @@ mod tests { // values[2] might be any value since it's null. // Check validity - first two should be valid, third should be null. + let mut ctx = LEGACY_SESSION.create_execution_ctx(); assert!( array .validity() .vortex_expect("primitive validity should be derivable") - .is_valid(0) + .execute_is_valid(0, &mut ctx) .unwrap() ); assert!( array .validity() .vortex_expect("primitive validity should be derivable") - .is_valid(1) + .execute_is_valid(1, &mut ctx) .unwrap() ); assert!( !array .validity() .vortex_expect("primitive validity should be derivable") - .is_valid(2) + .execute_is_valid(2, &mut ctx) .unwrap() ); diff --git a/vortex-array/src/builders/struct_.rs b/vortex-array/src/builders/struct_.rs index b7c737b3f1c..d396cf186a0 100644 --- a/vortex-array/src/builders/struct_.rs +++ b/vortex-array/src/builders/struct_.rs @@ -180,7 +180,7 @@ impl ArrayBuilder for StructBuilder { } self.nulls.append_validity_mask( - array + &array .validity() .vortex_expect("validity_mask") .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) @@ -196,8 +196,7 @@ impl ArrayBuilder for StructBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { diff --git a/vortex-array/src/builders/tests.rs b/vortex-array/src/builders/tests.rs index 53b010597cc..b725a4636b5 100644 --- a/vortex-array/src/builders/tests.rs +++ b/vortex-array/src/builders/tests.rs @@ -5,6 +5,8 @@ use std::sync::Arc; use rstest::rstest; use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_mask::Mask; use crate::LEGACY_SESSION; use crate::VortexSessionExecute; @@ -820,3 +822,67 @@ fn test_append_scalar_repeated_same_instance() { ); } } + +/// Test that `set_validity` correctly overrides a builder's validity across all mask variants. +/// +/// `set_validity` moves the mask's buffer into the builder rather than copying it, so the +/// `sliced_offset` case is important: slicing a `Mask::Values` at a non-byte-aligned boundary +/// yields a buffer with a non-zero bit offset, which the move path must preserve. +#[rstest] +#[case::all_true(Mask::new_true(8), vec![true; 8])] +#[case::all_false(Mask::new_false(8), vec![false; 8])] +#[case::values( + Mask::from_iter([true, false, true, true, false, false, true, false]), + vec![true, false, true, true, false, false, true, false] +)] +#[case::sliced_offset( + Mask::from_iter([ + false, false, false, // dropped by the slice + true, false, true, true, false, false, true, false, // kept: indices 3..11 + true, true, true, true, true, // dropped by the slice + ]) + .slice(3..11), + vec![true, false, true, true, false, false, true, false] +)] +fn test_set_validity_overrides_validity( + #[case] mask: Mask, + #[case] expected: Vec, +) -> VortexResult<()> { + let dtype = DType::Primitive(PType::I32, Nullability::Nullable); + let mut builder = builder_with_capacity(&dtype, mask.len()); + builder.append_zeros(mask.len()); + + builder.set_validity(mask); + + let validity = builder.finish().validity()?; + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + for (i, &valid) in expected.iter().enumerate() { + assert_eq!( + validity.execute_is_valid(i, &mut ctx)?, + valid, + "validity mismatch at index {i}" + ); + } + Ok(()) +} + +/// Test that `set_validity` is a no-op on a non-nullable builder. +#[test] +fn test_set_validity_noop_when_non_nullable() -> VortexResult<()> { + let dtype = DType::Primitive(PType::I32, Nullability::NonNullable); + let mut builder = builder_with_capacity(&dtype, 4); + builder.append_zeros(4); + + // Providing an all-false mask must not make the non-nullable array invalid. + builder.set_validity(Mask::new_false(4)); + + let validity = builder.finish().validity()?; + let mut ctx = LEGACY_SESSION.create_execution_ctx(); + for i in 0..4 { + assert!( + validity.execute_is_valid(i, &mut ctx)?, + "index {i} should remain valid" + ); + } + Ok(()) +} diff --git a/vortex-array/src/builders/varbinview.rs b/vortex-array/src/builders/varbinview.rs index 9150e8e70e1..392bef59997 100644 --- a/vortex-array/src/builders/varbinview.rs +++ b/vortex-array/src/builders/varbinview.rs @@ -206,7 +206,7 @@ impl VarBinViewBuilder { "Some buffers already exist", ); self.views_builder.extend_trusted(views.iter().copied()); - self.push_only_validity_mask(validity_mask); + self.push_only_validity_mask(&validity_mask); debug_assert_eq!(self.nulls.len(), self.views_builder.len()) } @@ -236,7 +236,7 @@ impl VarBinViewBuilder { } // Pushes a validity mask into the builder not affecting the views or buffers - fn push_only_validity_mask(&mut self, validity_mask: Mask) { + fn push_only_validity_mask(&mut self, validity_mask: &Mask) { self.nulls.append_validity_mask(validity_mask); } } @@ -299,17 +299,13 @@ impl ArrayBuilder for VarBinViewBuilder { let array = array.to_varbinview(); self.flush_in_progress(); - self.push_only_validity_mask( - array - .as_ref() - .validity() - .vortex_expect("validity_mask") - .execute_mask( - array.as_ref().len(), - &mut LEGACY_SESSION.create_execution_ctx(), - ) - .vortex_expect("Failed to compute validity mask"), - ); + let mask = array + .validity() + .vortex_expect("validity_mask") + .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) + .vortex_expect("Failed to compute validity mask"); + + self.push_only_validity_mask(&mask); let view_adjustment = self.completed @@ -325,41 +321,30 @@ impl ArrayBuilder for VarBinViewBuilder { .iter() .map(|view| adjustment.adjust_view(view)), ), - ViewAdjustment::Rewriting(adjustment) => { - match array - .as_ref() - .validity() - .vortex_expect("validity_mask") - .execute_mask( - array.as_ref().len(), - &mut LEGACY_SESSION.create_execution_ctx(), - ) - .vortex_expect("Failed to compute validity mask") - { - Mask::AllTrue(_) => { - for (idx, &view) in array.views().iter().enumerate() { - let new_view = self.push_view(view, &adjustment, &array, idx); - self.views_builder.push(new_view); - } - } - Mask::AllFalse(_) => { - self.views_builder - .push_n(BinaryView::empty_view(), array.len()); + ViewAdjustment::Rewriting(adjustment) => match mask { + Mask::AllTrue(_) => { + for (idx, &view) in array.views().iter().enumerate() { + let new_view = self.push_view(view, &adjustment, &array, idx); + self.views_builder.push(new_view); } - Mask::Values(v) => { - for (idx, (&view, is_valid)) in - array.views().iter().zip(v.bit_buffer().iter()).enumerate() - { - let new_view = if !is_valid { - BinaryView::empty_view() - } else { - self.push_view(view, &adjustment, &array, idx) - }; - self.views_builder.push(new_view); - } + } + Mask::AllFalse(_) => { + self.views_builder + .push_n(BinaryView::empty_view(), array.len()); + } + Mask::Values(v) => { + for (idx, (&view, is_valid)) in + array.views().iter().zip(v.bit_buffer().iter()).enumerate() + { + let new_view = if !is_valid { + BinaryView::empty_view() + } else { + self.push_view(view, &adjustment, &array, idx) + }; + self.views_builder.push(new_view); } } - } + }, } } @@ -369,8 +354,7 @@ impl ArrayBuilder for VarBinViewBuilder { } unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::new(validity.len()); - self.nulls.append_validity_mask(validity); + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); } fn finish(&mut self) -> ArrayRef { @@ -383,6 +367,7 @@ impl ArrayBuilder for VarBinViewBuilder { } impl VarBinViewBuilder { + #[inline] fn push_view( &mut self, view: BinaryView, @@ -626,8 +611,8 @@ impl BuffersWithOffsets { buffers: Arc::from( array .data_buffers() - .to_vec() - .into_iter() + .iter() + .cloned() .map(|b| b.unwrap_host()) .collect_vec(), ), @@ -774,6 +759,7 @@ enum PrecomputedViewAdjustment { } impl PrecomputedViewAdjustment { + #[inline] fn adjust_view(&self, view: &BinaryView) -> BinaryView { if view.is_inlined() { return *view; @@ -831,6 +817,7 @@ struct RewritingViewAdjustment { impl RewritingViewAdjustment { /// Can return None if this view can't be adjusted, because there is no precomputed lookup /// for the current buffer. + #[inline] fn adjust_view(&self, view: &BinaryView) -> Option { if view.is_inlined() { return Some(*view); diff --git a/vortex-array/src/builtins.rs b/vortex-array/src/builtins.rs index dcbe934097e..d3b27bb8753 100644 --- a/vortex-array/src/builtins.rs +++ b/vortex-array/src/builtins.rs @@ -14,6 +14,7 @@ use vortex_error::VortexResult; use crate::ArrayRef; use crate::IntoArray; use crate::arrays::ConstantArray; +use crate::arrays::InterleaveArray; use crate::arrays::scalar_fn::ScalarFnFactoryExt; use crate::dtype::DType; use crate::dtype::FieldName; @@ -67,6 +68,8 @@ pub trait ExprBuiltins: Sized { /// Conditional selection: `result[i] = if mask[i] then if_true[i] else if_false[i]`. fn zip(&self, if_true: Expression, if_false: Expression) -> VortexResult; + // TODO(joe): add an `interleave` expression builtin mirroring `ArrayBuiltins::interleave`. + /// Apply a binary operator to this expression and another. fn binary(&self, rhs: Expression, op: Operator) -> VortexResult; } @@ -140,6 +143,16 @@ pub trait ArrayBuiltins: Sized { /// Conditional selection: `result[i] = if mask[i] then if_true[i] else if_false[i]`. fn zip(&self, if_true: ArrayRef, if_false: ArrayRef) -> VortexResult; + /// Random-access gather by `(array_index, row_index)`: output row `i` is taken from + /// `values[array_indices[i]][row_indices[i]]`, where `self` is the (non-nullable) + /// `array_indices` selector and `row_indices` names the position within the selected value. + /// See [`InterleaveArray`]. + fn interleave( + &self, + values: impl IntoIterator, + row_indices: ArrayRef, + ) -> VortexResult; + /// Check if a list contains a value. fn list_contains(&self, value: ArrayRef) -> VortexResult; @@ -213,6 +226,17 @@ impl ArrayBuiltins for ArrayRef { Zip.try_new_array(self.len(), EmptyOptions, [if_true, if_false, self.clone()]) } + fn interleave( + &self, + values: impl IntoIterator, + row_indices: ArrayRef, + ) -> VortexResult { + Ok( + InterleaveArray::try_new(values.into_iter().collect(), self.clone(), row_indices)? + .into_array(), + ) + } + fn list_contains(&self, value: ArrayRef) -> VortexResult { ListContains .try_new_array(self.len(), EmptyOptions, [self.clone(), value])? diff --git a/vortex-array/src/canonical.rs b/vortex-array/src/canonical.rs index b1773d453f2..3c07650b2ef 100644 --- a/vortex-array/src/canonical.rs +++ b/vortex-array/src/canonical.rs @@ -258,11 +258,11 @@ impl Canonical { /// /// This operation is very expensive and can result in things like allocations, full-scans /// and copy operations. - pub fn compact(&self) -> VortexResult { + pub fn compact(&self, ctx: &mut ExecutionCtx) -> VortexResult { match self { Canonical::VarBinView(array) => Ok(Canonical::VarBinView(array.compact_buffers()?)), Canonical::List(array) => Ok(Canonical::List( - array.rebuild(ListViewRebuildMode::TrimElements)?, + array.rebuild(ListViewRebuildMode::TrimElements, ctx)?, )), _ => Ok(self.clone()), } @@ -563,9 +563,14 @@ impl Executable for CanonicalValidity { Canonical::Bool(b) => { let validity = child_to_validity(b.slots()[0].as_ref(), b.dtype().nullability()); let len = b.len(); - let BoolDataParts { bits, offset, len } = b.into_data().into_parts(len); + let BoolDataParts { bits, meta } = b.into_data().into_parts(len); Ok(CanonicalValidity(Canonical::Bool( - BoolArray::try_new_from_handle(bits, offset, len, validity.execute(ctx)?)?, + BoolArray::try_new_from_handle( + bits, + meta.offset(), + meta.len(), + validity.execute(ctx)?, + )?, ))) } Canonical::Primitive(p) => { @@ -713,9 +718,14 @@ impl Executable for RecursiveCanonical { Canonical::Bool(b) => { let validity = child_to_validity(b.slots()[0].as_ref(), b.dtype().nullability()); let len = b.len(); - let BoolDataParts { bits, offset, len } = b.into_data().into_parts(len); + let BoolDataParts { bits, meta } = b.into_data().into_parts(len); Ok(RecursiveCanonical(Canonical::Bool( - BoolArray::try_new_from_handle(bits, offset, len, validity.execute(ctx)?)?, + BoolArray::try_new_from_handle( + bits, + meta.offset(), + meta.len(), + validity.execute(ctx)?, + )?, ))) } Canonical::Primitive(p) => { @@ -1062,6 +1072,7 @@ pub struct AnyCanonical; impl Matcher for AnyCanonical { type Match<'a> = CanonicalView<'a>; + #[inline] fn matches(array: &ArrayRef) -> bool { array.is::() || array.is::() @@ -1075,6 +1086,7 @@ impl Matcher for AnyCanonical { || array.is::() } + #[inline] fn try_match(array: &ArrayRef) -> Option> { if let Some(a) = array.as_opt::() { Some(CanonicalView::Null(a)) @@ -1103,6 +1115,7 @@ impl Matcher for AnyCanonical { #[cfg(test)] mod test { use std::sync::Arc; + use std::sync::LazyLock; use arrow_array::Array as ArrowArray; use arrow_array::ArrayRef as ArrowArrayRef; @@ -1122,12 +1135,12 @@ mod test { use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_error::vortex_err; + use vortex_session::VortexSession; use crate::ArrayRef; use crate::Canonical; use crate::CanonicalValidity; use crate::IntoArray; - use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::Constant; use crate::arrays::ConstantArray; @@ -1142,6 +1155,11 @@ mod test { use crate::canonical::StructArray; use crate::dtype::Nullability; use crate::scalar::Scalar; + use crate::session::ArraySession; + + /// A shared session for these canonical tests, used to create execution contexts. + static SESSION: LazyLock = + LazyLock::new(|| VortexSession::empty().with::()); fn variant_core_storage(len: usize) -> ArrayRef { ConstantArray::new( @@ -1163,7 +1181,7 @@ mod test { let outer_variant = VariantArray::try_new(variant_core_storage(len), Some(inner_variant.into_array()))?; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let Canonical::Variant(canonical) = outer_variant .into_array() .execute::(&mut ctx)? @@ -1190,7 +1208,7 @@ mod test { #[test] fn test_canonicalize_nested_struct() { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); // Create a struct array with multiple internal components. let nested_struct_array = StructArray::from_fields(&[ ("a", buffer![1u64].into_array()), @@ -1210,7 +1228,7 @@ mod test { ]) .unwrap(); - let arrow_struct = LEGACY_SESSION + let arrow_struct = SESSION .arrow() .execute_arrow(nested_struct_array.into_array(), None, &mut ctx) .unwrap() @@ -1247,7 +1265,7 @@ mod test { #[test] fn roundtrip_struct() { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let mut nulls = NullBufferBuilder::new(6); nulls.append_n_non_nulls(4); nulls.append_null(); @@ -1280,7 +1298,7 @@ mod test { ); let vortex_struct = ArrayRef::from_arrow(&arrow_struct, true).unwrap(); - let vortex_struct = LEGACY_SESSION + let vortex_struct = SESSION .arrow() .execute_arrow(vortex_struct, None, &mut ctx) .unwrap(); @@ -1289,7 +1307,7 @@ mod test { #[test] fn roundtrip_list() { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = SESSION.create_execution_ctx(); let names = Arc::new(StringArray::from_iter(vec![ Some("Joseph"), Some("Angela"), @@ -1307,7 +1325,7 @@ mod test { let vortex_list = ArrayRef::from_arrow(&arrow_list, true).unwrap(); - let rt_arrow_list = LEGACY_SESSION + let rt_arrow_list = SESSION .arrow() .execute_arrow(vortex_list, Some(&list_field), &mut ctx) .unwrap(); diff --git a/vortex-array/src/display/mod.rs b/vortex-array/src/display/mod.rs index 3d121cf80b9..0134a28558c 100644 --- a/vortex-array/src/display/mod.rs +++ b/vortex-array/src/display/mod.rs @@ -579,19 +579,22 @@ impl ArrayRef { } #[cfg(feature = "table-display")] DisplayOptions::TableDisplay => { + use vortex_mask::Mask; + + use crate::arrays::StructArray; use crate::arrays::struct_::StructArrayExt; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; let mut builder = tabled::builder::Builder::default(); + // Reuse a single execution context across all per-row accesses below. + let mut ctx = LEGACY_SESSION.create_execution_ctx(); // Special logic for struct arrays. let DType::Struct(sf, _) = self.dtype() else { // For non-struct arrays, simply display a single column table without header. for row_idx in 0..self.len() { let value = self - .execute_scalar(row_idx, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(row_idx, &mut ctx) .map_or_else(|e| format!(""), |s| s.to_string()); builder.push_record([value]); } @@ -602,22 +605,27 @@ impl ArrayRef { return write!(f, "{table}"); }; - #[expect(deprecated)] - let struct_ = self.to_struct(); + let struct_ = match self.clone().execute::(&mut ctx) { + Ok(struct_) => struct_, + Err(e) => return write!(f, ""), + }; builder.push_record(sf.names().iter().map(|name| name.to_string())); + // Resolve validity to a mask once instead of probing it per row. + let validity = self + .validity() + .and_then(|v| v.execute_mask(self.len(), &mut ctx)) + .unwrap_or_else(|_| Mask::new_false(self.len())); + for row_idx in 0..self.len() { - if !self - .is_valid(row_idx, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap_or(false) - { + if !validity.value(row_idx) { let null_row = vec!["null".to_string(); sf.names().len()]; builder.push_record(null_row); } else { - let mut row = Vec::new(); + let mut row = Vec::with_capacity(struct_.struct_fields().nfields()); for field_array in StructArrayExt::iter_unmasked_fields(&struct_) { let value = field_array - .execute_scalar(row_idx, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(row_idx, &mut ctx) .map_or_else(|e| format!(""), |s| s.to_string()); row.push(value); } @@ -634,10 +642,7 @@ impl ArrayRef { } for row_idx in 0..self.len() { - if !self - .is_valid(row_idx, &mut LEGACY_SESSION.create_execution_ctx()) - .unwrap_or(false) - { + if !validity.value(row_idx) { table.modify( (1 + row_idx, 0), tabled::settings::Span::column(sf.names().len() as isize), diff --git a/vortex-array/src/dtype/arrow.rs b/vortex-array/src/dtype/arrow.rs index b107746e3e9..e03cf673c21 100644 --- a/vortex-array/src/dtype/arrow.rs +++ b/vortex-array/src/dtype/arrow.rs @@ -458,6 +458,18 @@ mod test { ); } + #[rstest] + #[case(1, DataType::Decimal128(1, 0))] + #[case(38, DataType::Decimal128(38, 0))] + #[case(39, DataType::Decimal256(39, 0))] + #[case(76, DataType::Decimal256(76, 0))] + fn test_decimal_dtype_to_arrow(#[case] precision: u8, #[case] expected: DataType) { + use crate::dtype::DecimalDType; + + let dtype = DType::Decimal(DecimalDType::new(precision, 0), Nullability::NonNullable); + assert_eq!(dtype.to_arrow_dtype().unwrap(), expected); + } + #[test] fn test_variant_dtype_to_arrow_dtype_errors() { let err = DType::Variant(Nullability::NonNullable) diff --git a/vortex-array/src/dtype/field.rs b/vortex-array/src/dtype/field.rs index 45ddb70c9a7..84f631b5074 100644 --- a/vortex-array/src/dtype/field.rs +++ b/vortex-array/src/dtype/field.rs @@ -239,18 +239,56 @@ impl Display for FieldPath { } #[derive(Default, Clone, Debug)] -/// Contains a set of field paths, and can answer an efficient field path contains queries. +/// A set of field paths supporting efficient `contains` queries. +/// +/// Paths are stored as inserted. Prefix-minimization—collapsing a path into an ancestor that +/// already covers it—is deferred until the set is iterated via [`IntoIterator`], so insertion stays +/// cheap. pub struct FieldPathSet { - /// While this is currently a set wrapper it can be replaced with a trie. + /// While this is currently a set wrapper it can be replaced with a trie, at which point the + /// deferred minimization in [`IntoIterator`] becomes cheap. // TODO(joe): this can be replaced with a `FieldPath` trie set: HashSet, } impl FieldPathSet { - /// Checks if a set contains a field path + /// Checks if the set contains exactly this field path. pub fn contains(&self, path: &FieldPath) -> bool { self.set.contains(path) } + + /// Iterates over the field paths in the set, as inserted (not prefix-minimized). + pub fn iter(&self) -> impl Iterator { + self.set.iter() + } + + /// Inserts a field path. Prefix-minimization is deferred until the set is iterated. + pub fn insert(&mut self, path: FieldPath) { + self.set.insert(path); + } +} + +/// Reduces field paths to their minimal covering set: any path that has another path in the set as +/// a prefix is redundant and dropped. +fn minimal_covering_set(paths: impl IntoIterator) -> Vec { + let mut covering: Vec = Vec::new(); + for path in paths { + if covering + .iter() + .any(|existing| path.parts().starts_with(existing.parts())) + { + continue; + } + covering.retain(|existing| !existing.parts().starts_with(path.parts())); + covering.push(path); + } + covering +} + +impl Extend for FieldPathSet { + fn extend>(&mut self, iter: T) { + self.set.extend(iter); + } } impl FromIterator for FieldPathSet { @@ -260,6 +298,16 @@ impl FromIterator for FieldPathSet { } } +impl IntoIterator for FieldPathSet { + type Item = FieldPath; + type IntoIter = std::vec::IntoIter; + + /// Iterates the prefix-minimal covering set: redundant descendants are dropped. + fn into_iter(self) -> Self::IntoIter { + minimal_covering_set(self.set).into_iter() + } +} + #[cfg(test)] mod tests { use super::*; @@ -418,4 +466,17 @@ mod tests { assert!(!path1.overlap(&path3)); assert!(!path3.overlap(&path1)); } + + #[test] + fn iteration_yields_minimal_covering_set() { + let mut paths = FieldPathSet::default(); + paths.extend([field_path!(a.b), field_path!(x), field_path!(a)]); + paths.insert(field_path!(a.c)); + + // Iteration collapses `a.b`/`a.c` into the covering `a`. + assert_eq!( + paths.into_iter().collect::>(), + HashSet::from_iter([field_path!(a), field_path!(x)]) + ); + } } diff --git a/vortex-array/src/expr/analysis/mod.rs b/vortex-array/src/expr/analysis/mod.rs index a0b07eb96e6..f5208a31be8 100644 --- a/vortex-array/src/expr/analysis/mod.rs +++ b/vortex-array/src/expr/analysis/mod.rs @@ -6,6 +6,7 @@ mod fallible; pub mod immediate_access; mod labeling; mod null_sensitive; +mod referenced_field_paths; pub use annotation::*; pub use fallible::label_is_fallible; @@ -13,3 +14,4 @@ pub use immediate_access::*; pub use labeling::*; pub use null_sensitive::BooleanLabels; pub use null_sensitive::label_null_sensitive; +pub use referenced_field_paths::referenced_field_paths; diff --git a/vortex-array/src/expr/analysis/referenced_field_paths.rs b/vortex-array/src/expr/analysis/referenced_field_paths.rs new file mode 100644 index 00000000000..5239e4a691c --- /dev/null +++ b/vortex-array/src/expr/analysis/referenced_field_paths.rs @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_error::VortexResult; +use vortex_error::vortex_err; + +use crate::dtype::DType; +use crate::dtype::Field; +use crate::dtype::FieldPath; +use crate::dtype::FieldPathSet; +use crate::expr::Expression; +use crate::expr::traversal::FoldDownContext; +use crate::expr::traversal::FoldUp; +use crate::expr::traversal::NodeExt; +use crate::expr::traversal::NodeFolderContext; +use crate::scalar_fn::fns::get_item::GetItem; +use crate::scalar_fn::fns::root::Root; +use crate::scalar_fn::fns::select::Select; + +/// Returns the rooted field paths referenced by an expression. +/// +/// Iterating the returned set (via [`IntoIterator`]) yields the prefix-minimal covering set: when +/// one referenced path is a prefix of another, only the prefix is kept. A standalone root +/// expression is represented by [`FieldPath::root`], which conservatively selects all fields. +/// Scalar functions other than `GetItem` and `Select` conservatively reference each complete child +/// output. +pub fn referenced_field_paths(expr: &Expression, scope: &DType) -> VortexResult { + // Validate the whole expression so plain GetItem paths and Select paths behave consistently. + expr.return_dtype(scope)?; + + let mut collector = ReferencedFieldPaths { + scope, + field_paths: FieldPathSet::default(), + }; + expr.clone() + .fold_context(&vec![FieldPath::root()], &mut collector)?; + let field_paths = collector.field_paths; + + // The top-level field of every referenced path must be one of the immediately accessed scope + // fields: this analysis only refines *which nested fields* are read, never which top-level + // fields. `FieldPath::root()` stands in for "all fields", so it expands to the whole scope. + #[cfg(debug_assertions)] + if let Some(scope_fields) = scope.as_struct_fields_opt() { + use vortex_utils::aliases::hash_set::HashSet; + + use crate::dtype::FieldName; + use crate::expr::analysis::immediate_access::immediate_scope_access; + + let referenced_heads: HashSet = if field_paths.iter().any(FieldPath::is_root) { + scope_fields.names().iter().cloned().collect() + } else { + field_paths + .iter() + .filter_map(|path| match path.parts().first() { + Some(Field::Name(name)) => Some(name.clone()), + _ => None, + }) + .collect() + }; + debug_assert_eq!( + referenced_heads, + immediate_scope_access(expr, scope_fields), + "referenced field path heads must match the immediately accessed scope fields" + ); + } + + Ok(field_paths) +} + +/// Threads the set of currently-requested field paths down the expression tree, narrowing it at +/// each `GetItem`/`Select`, and records the rooted paths reached at each `Root` leaf. +/// +/// Paths are carried reversed so a `GetItem` can `push` its field instead of prepending it; they +/// are reversed back to rooted order when recorded at a `Root`, and `Select` reads a path's head +/// from its last element. +/// +/// Narrowing is only sound through `GetItem` (a genuine field access) and `Select` (a genuine +/// column projection). Any other function is opaque—we cannot assume it preserves a field's +/// provenance—so its children conservatively re-request the whole scope, which is what keeps an +/// expression like `f($).x` reading every field of `$` rather than just `x`. +struct ReferencedFieldPaths<'a> { + scope: &'a DType, + field_paths: FieldPathSet, +} + +impl NodeFolderContext for ReferencedFieldPaths<'_> { + type NodeTy = Expression; + type Result = (); + type Context = Vec; + + fn visit_down( + &mut self, + requested: &Self::Context, + node: &Expression, + ) -> VortexResult> { + if node.is::() { + self.field_paths.extend( + requested + .iter() + .map(|path| FieldPath::from_iter(path.parts().iter().rev().cloned())), + ); + return Ok(FoldDownContext::Skip(())); + } + + if let Some(field_name) = node.as_opt::() { + let appended = requested + .iter() + .map(|path| path.clone().push(Field::Name(field_name.clone()))) + .collect(); + return Ok(FoldDownContext::Continue(appended)); + } + + // Keep requested paths whose head is included, expanding a whole-scope request into one + // path per included field. + if let Some(selection) = node.as_opt::